Skip to content

Commit 8a058d8

Browse files
fix(plugin-security): under group, a no-active-organization insert gets the ADR-0123 D2 403, not a 500 (#24819)
Fixes #24784 Clause-②: no ## What changed On the `group` tenancy posture (any union-scope posture), a member whose session has no active organization sent a create on a tenant-scoped object. The Layer 0 write wall in `plugin-security` let it through: its scope pre-test (`callerHasOrganizationScope`) reads the membership set under a union-scope posture, and a member's set is not empty. But a new row lands in the active organization (ADR-0105 D5), and there was none. So the engine met the row as an organization-less system write and refused it with `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing was written. The wall now refuses that insert itself, with the ADR-0123 D2 answer `isolated` already gave the same session: `403 PERMISSION_DENIED`, "[Security] Access denied: 'OBJECT' is scoped to an organization, and this session has no active organization — so this insert has no organization to place the record in. Join or select an active organization and retry." `packages/plugins/plugin-security/src/security-plugin.ts`, the step 3.7 write wall: - `carriesActiveOrganization(context)` is new, beside `callerHasOrganizationScope`. The non-union branch of `callerHasOrganizationScope` now calls it, with the same answer as before. - The ADR-0123 D2 throw is one local function, `refuseNoActiveOrganization`. The scope pre-test throws it as before, with the same message, details and operator text. The new arm throws it too, with its own operator text, which says what the context does and does not carry under this posture. - The new arm applies to an `insert`, under a union-scope posture, when the caller's own Layer 0 applies to the object and the context carries no `tenantId`. It judges the stored image, in the post-hook seam the engine already runs (`postHookWriteImageCheck`). That is the row after the `beforeInsert` chain, the same rows the engine's own organization check (`resolveSystemInsertOrganization`) reads one step later. It runs first in that seam, so the session's state is the reason reported. - The `canWriteObject` doc names the new arm among the post-image refusals it does not judge. `no-active-organization-write-refusal.test.ts`: the control "a NON-EMPTY membership set lands, even with no active organization" pinned exactly the branch this card changes. Its terminal stands in for the engine, so it never met the 500. It is replaced by the refusal case and its single-fact control: the same set with an active organization lands. `no-active-organization-insert-union-scope.test.ts` is new. It runs on the real stack: ObjectQL, SqlDriver and SecurityPlugin, booted the way `tenant-wall-post-hook-image.test.ts` boots them. ## Why the stored image, and not `organizationWallRefusal` I measured the base before choosing. Under `group` today, an insert whose own app `beforeInsert` hook places the row in an organization the membership wall admits is admitted, while `isolated` refuses it ahead of the hooks. A row-less change to the predicate in `organizationWallRefusal` would refuse it too. That turns a 2xx into a 403, which is outside "the change turns a 500 into the designed 403". The stored image asks exactly the engine's question, so every answer this PR changes was a 500 before. The cost: `canWriteObject` is asked about no row. It keeps answering `true` for this caller's create preview, as it did before. Its doc already lists post-image refusals as outside its answer, and now names this arm there. ## Readings: real stack, in process A member of `org_a` and `org_c`, no active organization, on a public tenant object. Base `a43405d596`, fix `cca0ba958a`: | case | `group` before | `group` after | `isolated` before and after | |:--|:--|:--|:--| | create | 500 `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` | 403 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | create, a payload naming a member organization | 500 | 403 | 403 | | platform operator with no active org, public tenant object | 500 | 403 | 403 | | platform operator with no active org, private object | 500 | 500 (unchanged, see notes) | 500 | | create whose app hook places the row in a member organization | 2xx | 2xx (unchanged) | 403 | | same member with an active organization: create | 2xx, row in `org_a` | 2xx, row in `org_a` | not run | | update by id / by predicate of a member organization's row | 200 | 200 | 404 (row not visible) | | delete of a member organization's row | 200 | 200 | 404 | | read | member orgs' rows | member orgs' rows | not run | The engine's static read-only strip drops a session payload's `organization_id` on insert. So a payload value never decides where a session's new row lands, before or after this change. ## Which verbs ADR-0123 D2 and the checklist item cover - `access-security.no-active-org-session-semantics`: acceptance[1] and step 3 test a create, and acceptance[5], acceptance[6] and negative[3] require no 500 anywhere. Covered for insert, single and bulk. - Update: ADR-0123 D2 names update. Under `group` it states the scope as the membership set. A no-active-org member's update of a row that set admits answers 200 before and after, and the row keeps its own organization. Nothing has nowhere to land, so it is not changed. - Delete: this is the ADR-0123 D2 boundary, and it is not changed. ## Tests (head `94f661fbea`, the merge of `origin/main` `307ce4c4b4`) - The real-stack pin on the base `a43405d596`, before the fix: 4 failed, 2 passed. `group` answered `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` / 500 where `isolated` answered `PERMISSION_DENIED` / 403. The fourth failure was the active-org control's sort order in the test itself, fixed before the fix commit. - `pnpm --filter @objectstack/plugin-security test`: 193 files passed, 4013 passed, 45 skipped. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, and `check:test-typecheck` is OK. `tsc -p tsconfig.test.json --listFiles` lists both test files among the package's test files. - Both ran in one `&&`-joined command under the verify lock, after the closure build (turbo, `--filter='@objectstack/plugin-security...'`, 19/19 tasks). The lock printed `VERDICT command-exit 0`. Ablation, on the committed fix, run on `cca0ba958a` and again on `94f661fbea`. `node scripts/ablation-replace.mjs` replaced the arm's last conjunct with a constant false, which puts the old predicate back: - On disk: the anchor went from 1 to 0, the replacement from 0 to 1, and the blob changed (`0f12f626` to `4c7f7ebf` on the final head). - Result, both times: 4 failed, 22 passed. The ablated `group` case received `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, the equality-with-`isolated` case failed, and the unit case was not refused. Every control stayed green. - Restored with `git checkout HEAD -- PATH`: the blob equals HEAD's, and `git diff HEAD` and `git status` are empty. - The first attempt was a no-op. Its replacement contained the anchor, so the tool refused and restored before any test ran. It is discarded. ## Gates (local, head `94f661fbea`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 69 commands for this diff. All 69 exited 0, and each exit was captured before any pipe. - `--ran` over that record answered "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN" and exited 0. - `check:i18n` and `check:dual-build-cjs-loads` first answered exit 3 (`PREREQUISITE NOT MET`, nothing measured) on an earlier head. Both measured green once the builds they name existed. - These run in CI, not locally: the repo-wide scans (`pnpm lint` first), `test:repo`, Test Core, Build Core and the Type Check lanes. The main-only battery (dogfood, Temporal Conformance) runs after merge. This PR's check-runs decide them. - The derivation warned STALE TREE: `origin/main` was 6 commits past this head's merge base when it last ran. The queue rebuilds on current `main`. ## Acceptance notes - ADR-0123 D2 says "Under `group`, 'no organization scope' means an empty membership set". That sentence now describes the scope pre-test only. A union-scope insert with no active organization is also refused, with the same answer. This is the card's ruled answer (triage record 6285293541). An ADR addendum or status-line amendment is a governed docs change, not part of this PR. - Same family, not changed here (the dispatch keeps the operator path as it is): a platform operator with no active organization, creating on a posture-permitting tenant object, gets `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` under both `group` and `isolated`. This is an in-process reading only, not measured at the HTTP door. The card's second candidate, the engine mapping a non-system caller to a 4xx, would cover it. - Observed in passing, in process, not measured at the HTTP door: under `group`, a member with active organization `org_a` whose payload names `org_c` from the same set gets a 2xx, and the row is stored in `org_a`. The payload value is dropped by the engine's read-only strip, not refused. The organizations runtime's stamping comment says a supplied value is admitted or refused, never rewritten. - `packages/objectql/src/engine.ts` still says, at the insert post-image seam, that the Layer 0 tenant wall judges only the pre-hook image. That has been stale since the wall gained its stored-image judgement. It is out of bounds for this PR. - Named for reference only: #24783 holds the round's evidence and remains open; #21476 is the anonymous public-form submit, a different caller; #24343 is NULL `organization_id` rows from framework writers and remains open. This PR changes none of them. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7282a95 commit 8a058d8

4 files changed

Lines changed: 373 additions & 20 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): under the `group` posture, a tenant-scoped insert from a session with no active organization is refused 403 naming the missing organization, as `isolated` already answered, instead of a 500
6+
7+
Clause-②: no (no accept set moves: the insert was refused before and is refused now, with the ADR-0123 D2 answer in place of an internal error)
8+
9+
- **Before.** On `group` (any union-scope posture), a member of an organization whose session had no active organization got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` for a create on a tenant-scoped object, logged as an unhandled REST error.
10+
- **After.** That create gets `403 PERMISSION_DENIED` with the message `isolated` gives the same session, which names the missing active organization and tells the user to join or select one.
11+
- **Unchanged.** Nothing was written before and nothing is written now. The refusal judges the row as it is about to be stored, the same row the engine's own organization check reads, so it covers exactly the inserts that answered 500 and no insert that was admitted. Reads still span the member's organizations. Updates and deletes of rows the membership wall admits, inserts from a session with an active organization, system contexts, a platform operator on a posture-permitting object, the delegator's leg, and every `isolated` and `single` answer are as before.
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0123 D2 · ADR-0105 D2/D5] A tenant-scoped INSERT from a session with no
5+
* active organization gets the ADR-0123 D2 refusal under EVERY walled posture,
6+
* `group` included — measured on the real stack (ObjectQL + SqlDriver +
7+
* SecurityPlugin), never a 500 and never a row.
8+
*
9+
* ## The rule
10+
*
11+
* Under `group` the caller's membership set (`accessible_org_ids`) bounds what
12+
* it may READ (ADR-0105 D2), and a new row lands in the ACTIVE organization,
13+
* the default write target the engine stamps from `tenantId` (ADR-0105 D5). A
14+
* session with no active organization therefore has no organization to place a
15+
* new row in. ADR-0123 D2 answers that state with `403 PERMISSION_DENIED`, a
16+
* message naming the missing active organization, and `isolated` always did.
17+
*
18+
* `group` used to let that insert through the Layer 0 wall, whose scope test
19+
* reads the membership set there, and the engine then refused the row as an
20+
* organization-less SYSTEM write (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`,
21+
* status 500). Nothing was stored, so it failed closed; the defect was the
22+
* answer. The wall now judges the stored image in that case and refuses it
23+
* with the same D2 answer, before the engine is reached.
24+
*
25+
* ## What this file pins
26+
*
27+
* - `group`: refused with the D2 answer, and no row is stored;
28+
* - `isolated` (control, unchanged): the same context gets the same answer —
29+
* the two outcomes are compared whole, so "exactly what `isolated` answers"
30+
* is measured rather than restated;
31+
* - `group` (control): the same member WITH an active organization is
32+
* admitted, and the row lands in that organization;
33+
* - `group` (control): a session with NO membership at all keeps the D2
34+
* refusal it already had;
35+
* - `group` (control): reads with no active organization still span the
36+
* membership set — the posture's design, untouched.
37+
*/
38+
39+
import { describe, it, expect, afterEach, vi } from 'vitest';
40+
import { ObjectQL } from '@objectstack/objectql';
41+
import { SqlDriver } from '@objectstack/driver-sql';
42+
import type { PermissionSet } from '@objectstack/spec/security';
43+
import { SecurityPlugin } from './security-plugin.js';
44+
45+
/** Two organizations the member belongs to, and one it does not. */
46+
const OWN_ORG = 'org_a';
47+
const SISTER_ORG = 'org_c';
48+
const OTHER_ORG = 'org_b';
49+
50+
/** A public tenant object: the platform injects `organization_id`, so Layer 0 walls it. */
51+
const OBJECTS = [
52+
{
53+
name: 'qa_task',
54+
label: 'Task',
55+
fields: {
56+
id: { name: 'id', type: 'text', primaryKey: true },
57+
name: { name: 'name', type: 'text' },
58+
},
59+
},
60+
];
61+
62+
/** Plain CRUD and no row-level policy — Layer 0 is the only enforcer under test. */
63+
const MEMBER: PermissionSet = {
64+
name: 'member_default',
65+
label: 'Member',
66+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
67+
} as unknown as PermissionSet;
68+
69+
const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: OWN_ORG };
70+
71+
/**
72+
* The subject: an authenticated member of two organizations whose session has
73+
* no active organization. ONE context for every posture, so the `isolated`
74+
* control differs from the `group` case in the posture alone.
75+
*/
76+
const NO_ACTIVE_ORG_CTX = {
77+
userId: 'usr_a',
78+
positions: [],
79+
permissions: [],
80+
posture: 'MEMBER',
81+
accessible_org_ids: [OWN_ORG, SISTER_ORG],
82+
};
83+
/** The same member one `set-active` later — the single-fact control. */
84+
const ACTIVE_ORG_CTX = { ...NO_ACTIVE_ORG_CTX, tenantId: OWN_ORG };
85+
/** A session that belongs to no organization at all. */
86+
const NO_MEMBERSHIP_CTX = { userId: 'usr_z', positions: [], permissions: [], posture: 'MEMBER', accessible_org_ids: [] };
87+
88+
type Posture = 'isolated' | 'group';
89+
90+
const engines: ObjectQL[] = [];
91+
afterEach(async () => {
92+
while (engines.length) {
93+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
94+
}
95+
});
96+
97+
interface Booted {
98+
engine: ObjectQL;
99+
/** The table read straight off the driver, past every scope. */
100+
table: () => Promise<Array<Record<string, unknown>>>;
101+
}
102+
103+
const SEEDED = [
104+
{ id: 'r1', name: 'own', organization_id: OWN_ORG },
105+
{ id: 'r2', name: 'other', organization_id: OTHER_ORG },
106+
{ id: 'r3', name: 'sister', organization_id: SISTER_ORG },
107+
];
108+
109+
async function boot(posture: Posture): Promise<Booted> {
110+
const engine = new ObjectQL();
111+
engine.registerDriver(
112+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never,
113+
true,
114+
);
115+
await engine.init();
116+
engine.registerApp({
117+
id: 'com.objectstack.qa.no-active-organization-insert-union-scope',
118+
name: 'No active organization, union-scope posture',
119+
version: '1.0.0',
120+
type: 'plugin',
121+
scope: 'system',
122+
objects: OBJECTS,
123+
} as never);
124+
await engine.syncSchemas();
125+
engines.push(engine);
126+
127+
// The posture the package's other real-stack posture pins boot: the
128+
// `org-scoping` sentinel activates the wall and `tenancy` names the posture,
129+
// which SecurityPlugin also hands the engine (`setTenancyPostureProvider`).
130+
const services: Record<string, unknown> = {
131+
manifest: { register: vi.fn() },
132+
objectql: engine,
133+
metadata: {
134+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
135+
list: async () => [MEMBER],
136+
},
137+
'org-scoping': { name: 'com.objectstack.org-scoping' },
138+
tenancy: { posture },
139+
};
140+
const ctx = {
141+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
142+
registerService: vi.fn(),
143+
getService: (name: string) => {
144+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
145+
return services[name];
146+
},
147+
};
148+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
149+
await plugin.init(ctx as never);
150+
await plugin.start(ctx as never);
151+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
152+
153+
await engine.insert('qa_task', SEEDED.map((r) => ({ ...r })), { context: SYS_CTX } as never);
154+
155+
const table = async () => {
156+
const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver('qa_task');
157+
const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise<Array<Record<string, unknown>>> };
158+
const rows = await knex('qa_task').select('id', 'name', 'organization_id');
159+
return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id)));
160+
};
161+
return { engine, table };
162+
}
163+
164+
interface Outcome { ok: boolean; code?: string; status?: number; message?: string }
165+
166+
const attempt = async (run: () => Promise<unknown>): Promise<Outcome> => {
167+
try {
168+
await run();
169+
return { ok: true };
170+
} catch (e) {
171+
const err = e as { code?: string; statusCode?: number; status?: number; message?: string };
172+
return { ok: false, code: err.code, status: err.statusCode ?? err.status, message: String(err.message ?? e) };
173+
}
174+
};
175+
176+
const create = (b: Booted, data: unknown, context: object) =>
177+
b.engine.insert('qa_task', data as never, { context } as never);
178+
179+
/** The ADR-0123 D2 answer on the ADR-0112 envelope: `code` + `status`, then the D4 naming. */
180+
const expectNoActiveOrganizationRefusal = (outcome: Outcome) => {
181+
expect(outcome.ok, 'expected a refusal, got a completed write').toBe(false);
182+
expect(outcome.code, 'ADR-0112 error code').toBe('PERMISSION_DENIED');
183+
expect(outcome.status, 'ADR-0112 HTTP status').toBe(403);
184+
// ADR-0123 D4: the refusal names what is missing, so it cannot be mistaken
185+
// for an ordinary permission denial.
186+
expect(outcome.message).toMatch(/no active organization/i);
187+
};
188+
189+
describe('[ADR-0123 D2] a tenant-scoped insert from a session with no active organization — real stack', () => {
190+
it('group: is refused 403 PERMISSION_DENIED naming the missing active organization, and stores nothing', async () => {
191+
const b = await boot('group');
192+
193+
const outcome = await attempt(() => create(b, { id: 'n1', name: 'new' }, NO_ACTIVE_ORG_CTX));
194+
195+
expectNoActiveOrganizationRefusal(outcome);
196+
expect(await b.table(), 'no row stored, NULL-organization or otherwise').toEqual(SEEDED);
197+
});
198+
199+
it('group: a bulk insert is refused as a whole, and stores nothing', async () => {
200+
const b = await boot('group');
201+
202+
const outcome = await attempt(() => create(b, [{ id: 'n1', name: 'a' }, { id: 'n2', name: 'b' }], NO_ACTIVE_ORG_CTX));
203+
204+
expectNoActiveOrganizationRefusal(outcome);
205+
expect(await b.table()).toEqual(SEEDED);
206+
});
207+
208+
it('isolated (control, unchanged): the same context gets the same refusal, and group answers exactly what isolated answers', async () => {
209+
const isolated = await boot('isolated');
210+
const group = await boot('group');
211+
212+
const isolatedOutcome = await attempt(() => create(isolated, { id: 'n1', name: 'new' }, NO_ACTIVE_ORG_CTX));
213+
const groupOutcome = await attempt(() => create(group, { id: 'n1', name: 'new' }, NO_ACTIVE_ORG_CTX));
214+
215+
expectNoActiveOrganizationRefusal(isolatedOutcome);
216+
expect(await isolated.table()).toEqual(SEEDED);
217+
expect(groupOutcome, 'group answers exactly what isolated answers').toEqual(isolatedOutcome);
218+
});
219+
220+
it('group (control): the same member WITH an active organization is admitted, and the row lands in it', async () => {
221+
const b = await boot('group');
222+
223+
const outcome = await attempt(() => create(b, { id: 'n1', name: 'new' }, ACTIVE_ORG_CTX));
224+
225+
expect(outcome, 'expected the write to be admitted').toEqual({ ok: true });
226+
expect(await b.table()).toEqual([{ id: 'n1', name: 'new', organization_id: OWN_ORG }, ...SEEDED]);
227+
});
228+
229+
it('group (control): a session with no membership at all keeps the D2 refusal', async () => {
230+
const b = await boot('group');
231+
232+
const outcome = await attempt(() => create(b, { id: 'n1', name: 'new' }, NO_MEMBERSHIP_CTX));
233+
234+
expectNoActiveOrganizationRefusal(outcome);
235+
expect(await b.table()).toEqual(SEEDED);
236+
});
237+
238+
it('group (control): reads with no active organization still span the membership set', async () => {
239+
const b = await boot('group');
240+
241+
const rows = (await b.engine.find('qa_task', { context: NO_ACTIVE_ORG_CTX } as never)) as Array<Record<string, unknown>>;
242+
243+
expect(rows.map((r) => r.id).sort()).toEqual(['r1', 'r3']);
244+
});
245+
});

‎packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@
3838
*
3939
* Every control below is paired with the case it controls: for each exemption
4040
* (system context, platform operator, non-tenant object, `single` posture, a
41-
* non-empty `group` membership set) there is a sibling case differing in that
42-
* one fact where the refusal DOES fire. A file of controls alone would pass on
43-
* the pre-fix build.
41+
* `group` membership set WITH an active organization) there is a sibling case
42+
* differing in that one fact where the refusal DOES fire. A file of controls
43+
* alone would pass on the pre-fix build.
4444
*/
4545

4646
import { describe, it, expect, vi } from 'vitest';
@@ -289,15 +289,28 @@ describe('[ADR-0123 D2] tenant-scoped WRITES with no active organization are ref
289289
expectConformingRefusal(err);
290290
});
291291

292-
it('CONTROL: a NON-EMPTY membership set lands, even with no active organization', async () => {
293-
// The half a `tenantId`-only test would get wrong: under `group` the
294-
// membership set IS the scope, so this caller is fully scoped despite
295-
// carrying no active organization.
292+
it('a NON-EMPTY membership set with NO active organization: an insert has nowhere to land, and is refused the same way', async () => {
293+
// The membership set is the READ scope under `group`, so this caller
294+
// passes the scope pre-test; a new row still lands in the ACTIVE
295+
// organization (ADR-0105 D5), and there is none. The stored image is
296+
// judged — the terminal here runs the seam on the rows as sent. The
297+
// real-stack twin, with the engine and a driver, is
298+
// `no-active-organization-insert-union-scope.test.ts`.
296299
const h = await boot({ posture: 'group' });
297-
await h.run({
300+
const err = await refusalFrom(h.run, {
298301
object: 'task', operation: 'insert', data: { name: 'A' },
299302
context: { ...ORG_LESS, accessible_org_ids: ['org-1', 'org-2'] },
300303
});
304+
expectConformingRefusal(err);
305+
});
306+
307+
it('CONTROL: the same membership set WITH an active organization lands', async () => {
308+
// The single-fact control for the case above: only `tenantId` differs.
309+
const h = await boot({ posture: 'group' });
310+
await h.run({
311+
object: 'task', operation: 'insert', data: { name: 'A' },
312+
context: { ...ORG_BOUND, accessible_org_ids: ['org-1', 'org-2'] },
313+
});
301314
});
302315
});
303316

0 commit comments

Comments
 (0)