Skip to content

Commit 3b0b577

Browse files
fix(app-showcase): enforce the invoice ownership check on create as well as update (#27416)
## 维护者速读 - 改了什么:showcase 的 contributor 权限集给发票加了一条"新建时归属校验"(`invoice_owner_on_create`,`operation: 'insert'` 的 RLS `check`),与已有的"修改时归属校验"`invoice_owner_immutable` 成对。 - 为什么改:原有校验只覆盖修改,新建(含克隆)不受约束;现在 contributor 新建或克隆的发票必须归属自己,与其读取范围一致。 - 风险与回滚:只改示例应用的权限声明,平台代码零改动;contributor 新建发票时不填 `owner` 也会被拒(403),回滚即删掉这一条策略。 - 先看:`examples/app-showcase/src/security/permission-sets.ts`(新增策略那一段),再看 `packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts` 末尾新增用例。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/showcase-invoice-seed-isolation.dogfood.test.ts`,预期 19 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `examples/app-showcase/src/security/permission-sets.ts`: the `showcase_contributor` set gains `invoice_owner_on_create`, an `operation: 'insert'` RLS policy carrying the same `check: 'owner == current_user.email'` as the existing update-time `invoice_owner_immutable` (ADR-0058 D4). The platform already enforces an insert `check` through the post-image seam (`postHookWriteImageCheck`, plugin-security `writeCheckPolicies`); the update-only declaration simply did not apply to creates, and a `select` policy's `using` never stands in as an insert check. A separate `insert` policy is used rather than widening the existing rule to `all`, which would also make a check-only policy applicable to select and delete. Clone is a create and is judged by the same rule. - `packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts`: per holder shape (direct set grant and `contributor` position), pins both halves on the shipped set: an own-owner create lands 201 and is readable by its creator; a foreign-owner create and an absent-owner create are refused 403 `PERMISSION_DENIED` with nothing written; a foreign-owner clone is refused 403 with nothing written while a same-owner clone lands 201. One more case pins that the admin is unaffected (creates for any owner, 201). - Platform checklist text kept accurate: `approvals.account-app-entry` (rev 7) and `access-security.owd-sharing-matrix` (rev 7) no longer describe an owner-less contributor create as landing; the `search` area recipe `qa-contributor-bound-member` says the same. No platform code changes, so no changeset: `examples/app-showcase` and `packages/qa/dogfood` are private and `docs/qa/**` does not publish. ## Tests Run on the final head (sha below). <details><summary>Dogfood: the isolation file, 19/19</summary> `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/showcase-invoice-seed-isolation.dogfood.test.ts`: `Test Files 1 passed (1)` · `Tests 19 passed (19)`. </details> <details><summary>Ablation of the new policy: 4 refusal cases red, then restored</summary> `node scripts/ablation-replace.mjs` renamed the policy and narrowed it to a position no contributor holds (anchor 1 → 0, blob 3caf89d → 372c5ac8ebb9). The same file then answered `Tests 4 failed | 15 passed (19)`: both holder shapes' foreign-owner create (`expected 201 to be 403`) and foreign-owner clone (`foreign-owner clone: expected 201 to be 403`). Restore was proven by `blob == HEAD (3caf89d)` and an empty `git diff HEAD`. (A first attempt was a no-op, refused by the script because its replacement text contained the anchor. It was re-run with a different anchor.) </details> <details><summary>Neighbouring suites</summary> - Six showcase security dogfood files (isolation, d3-d4 capabilities, CRUD persona matrix, line contributor pair, expand CRUD gate, permission zoo): `Test Files 6 passed (6)` · `Tests 100 passed (100)`. - Every other dogfood file naming `showcase_invoice`: `Test Files 14 passed | 1 skipped (15)` · `Tests 81 passed | 8 skipped (89)`. - `@objectstack/example-showcase`: `typecheck` exit 0; `vitest run` `Test Files 41 passed (41)` · `Tests 505 passed (505)`. - `@objectstack/dogfood` `typecheck` exit 0. </details> ## Acceptance notes - A contributor's create that omits `owner` is now refused 403 too, because `owner` is a plain text field with no default. Before, that create landed and its creator could not see it. Giving `owner` a current-user default would be a separate showcase change. - Other showcase objects with email-based owner RLS: only `showcase_task` (`task_own_rows`, `assignee == current_user.email`, select only). It carries no ownership `check` at all, and assigning a task to a colleague is a legitimate create, so it is not the same shape. It is deliberately left out. - `docs/qa/platform-checklist/areas/search.json` (recipe step 3 of `qa-contributor-bound-member`) still says `invoice_own_rows` lists `positions: ['contributor']`. The shipped policy lists no positions. That text existed before this PR and is not touched here. ## Verification All readings above and below were taken at head `176cd526` (objectstack-ai/objectstack, merge base `7e200b4a6`). <details><summary>Derived local gate set: 57 run, 2 not measured</summary> `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 59 families for the 5 changed paths. 57 exited 0. `check:query-options-erasure` hit a 240 s runner timeout once, then was re-run alone and exited 0 (`ratchet holds: 50 unswept non-test site(s)`). `check:platform-checklist` and `check:nul-bytes` exited 0. Two families are NOT MEASURED. Each exited 3 (PREREQUISITE NOT MET), so neither measured anything: - `check-plugin-teardown-shape --self-test` needs an unshallow clone for its pinned fixture. - `check:dual-build-cjs-loads` needs a whole-workspace build. Neither reads a path this diff touches, and CI runs both. The `--ran` reconciliation (with exit codes) answered: `59 derived famil(ies) accounted for — 57 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. </details> The repo-wide families (`pnpm lint`, `test:repo`, the full dogfood battery) are left to CI on this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcV2ijbfB8Drvs2oik6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac9c2f0 commit 3b0b577

5 files changed

Lines changed: 117 additions & 7 deletions

File tree

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@
660660
"title": "Sharing-model / OWD matrix: private, public_read, public_read_write, controlled_by_parent each enforce their declared baseline",
661661
"since": "v15",
662662
"status": "active",
663-
"revision": 6,
663+
"revision": 7,
664664
"priority": "P0",
665665
"surface": "api",
666666
"personas": [
@@ -674,7 +674,7 @@
674674
"app": "showcase",
675675
"requires": [
676676
"one seeded object per model, from the access-matrix sharingModel column: showcase_private_note (private), showcase_announcement (public_read), showcase_account + showcase_project (public_read_write), showcase_invoice_line under showcase_invoice (controlled_by_parent)",
677-
"contributor invoice RLS owner == current_user.email (permission-sets.ts invoice_own_rows) — invoices must be created with owner set to the creating contributor's email"
677+
"contributor invoice RLS owner == current_user.email (permission-sets.ts invoice_own_rows) — invoices must be created with owner set to the creating contributor's email — a contributor create (clone included) whose owner is absent or names anyone else is refused 403 PERMISSION_DENIED by the create-time check invoice_owner_on_create, nothing written"
678678
]
679679
},
680680
"steps": [
@@ -791,6 +791,12 @@
791791
"date": "2026-10-27",
792792
"change": "Access-security pin upgrade, slice 1 (#26571; Tier 1 record #26468 scored the item PARTIAL). Pinned at the api oracle's layer (in-process HTTP): acceptance[0]'s admin re-read of the refused PATCH and its non-2xx by-id read (showcase-private-owd); acceptance[1]'s >= 400 non-owner PATCH with the admin re-read (showcase-public-read-owd); acceptance[4] on the stock C1/C2 contributors with C2's list exclusion, and acceptance[5]'s auditor listing of derived lines (new showcase-invoice-line-contributor-pair). automated.ref also names the existing showcase-crud-persona-matrix cases that already pin acceptance[2] and acceptance[3], which revision 5 recorded as unpinned. acceptance[6] stays procedural. No clause or status changed",
793793
"ref": "#26571"
794+
},
795+
{
796+
"revision": 7,
797+
"date": "2026-10-29",
798+
"change": "fixtures.requires[1] says what a contributor create with a foreign or absent owner answers: the showcase contributor set now carries the ownership check on insert as well as update (invoice_owner_on_create beside invoice_owner_immutable, examples/app-showcase/src/security/permission-sets.ts), so it is refused 403 PERMISSION_DENIED with nothing written, clone included. No clause changed",
799+
"ref": "claude/harden-invoice-owner-check-on-create"
794800
}
795801
]
796802
},

‎docs/qa/platform-checklist/areas/approvals.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,7 @@
11001100
"title": "A non-admin reaches the full Approvals Inbox from the account app's Approvals entry",
11011101
"since": "v17",
11021102
"status": "active",
1103-
"revision": 6,
1103+
"revision": 7,
11041104
"priority": "P0",
11051105
"surface": "browser",
11061106
"personas": ["a NON-ADMIN business user who is a pending approver and has no Setup access"],
@@ -1109,7 +1109,7 @@
11091109
"requires": [
11101110
"a bundled console: packages/console/dist is absent in a fresh checkout — build it with `pnpm console:build`, which builds ui/apps/console from this checkout and copies it to packages/console/dist (nothing is cloned or pinned: packages/console/build-dist.sh), and keep the ready line it prints. `pnpm build` builds the console but never refreshes that copy, so a dist older than the tree is the stale-console-bundle trap (RUNNER.md) — rebuild with `pnpm console:build` before trusting anything in the browser",
11111111
"a non-admin sign-in account holding only business positions (showcase: contributor + finance) — the seeded personas (Ada Auditor, Mei Phone) ARE loginable since #9308 (showcase123, examples/app-showcase/src/security/demo-personas.ts) but hold the wrong positions (Mei none, Ada auditor only), so provision one at runtime and invite each one first — the stock showcase declares no auth.audience, so the invite_only default answers an uninvited POST /api/v1/auth/sign-up/email 403 SELF_REGISTRATION_CLOSED; an admin POST /api/v1/auth/organization/invite-member for the address admits the sign-up (or admin POST /api/v1/auth/admin/create-user, which answers mustChangePassword:true)",
1112-
"at least one pending request routed to that user (showcase: PATCH a draft invoice to status=sent + issued_on to launch showcase_invoice_signoff, whose finance/legal positions the persona holds). ⚠️ Launch a NEW one after the persona holds its positions: the sign-off requests seed-approval-demo.ts launches at boot were routed before the runtime persona held finance, so they never list it as a pending approver. Draft it against an account that has not churned: issuing against a churned account is refused 400 'This account has churned — reactivate it before invoicing.' (examples/app-showcase/src/data/objects/invoice.object.ts), and the first account a plain list returns may be churned. Make that invoice one the persona OWNS — acceptance[5] needs a row whose record the persona can read. ⚠️ Ownership here is the TEXT field `owner`, not owner_id: the contributor RLS invoice_own_rows reads `owner == current_user.email` (examples/app-showcase/src/security/permission-sets.ts), so an invoice the persona creates gets owner_id = the persona and `owner` null, and its own creator reads it 404 until an admin PATCHes `owner` to the persona's email"
1112+
"at least one pending request routed to that user (showcase: PATCH a draft invoice to status=sent + issued_on to launch showcase_invoice_signoff, whose finance/legal positions the persona holds). ⚠️ Launch a NEW one after the persona holds its positions: the sign-off requests seed-approval-demo.ts launches at boot were routed before the runtime persona held finance, so they never list it as a pending approver. Draft it against an account that has not churned: issuing against a churned account is refused 400 'This account has churned — reactivate it before invoicing.' (examples/app-showcase/src/data/objects/invoice.object.ts), and the first account a plain list returns may be churned. Make that invoice one the persona OWNS — acceptance[5] needs a row whose record the persona can read. ⚠️ Ownership here is the TEXT field `owner`, not owner_id: the contributor RLS invoice_own_rows reads `owner == current_user.email` (examples/app-showcase/src/security/permission-sets.ts), so the persona's create must carry `owner` = its own email: a contributor create whose `owner` is absent or names anyone else is refused 403 PERMISSION_DENIED with nothing written, by the create-time ownership check invoice_owner_on_create (the insert twin of invoice_owner_immutable, same file)"
11131113
]
11141114
},
11151115
"steps": [
@@ -1203,6 +1203,12 @@
12031203
"date": "2026-10-28",
12041204
"change": "fixtures.requires[2] says the invoice's account must be active, and acceptance[3] names the door for the appended row. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26481, priority:P0, at 756220c4; wave anchor #25807, slice B): the first seeded account the runner picked was churned and the issue answered 400 'This account has churned'; and acceptance[3]'s evidence door, GET /approvals/requests/:id, carries no timeline. Re-read at base fd246c22: invoice.object.ts's churned-account rule, packages/rest/src/approvals-routes.ts (GET …/approvals/requests/:id/actions). The run's third line, that acceptance[0] has the runner build the console itself, is the clause's design and is not changed. No clause's assertion changed",
12051205
"ref": "#25807"
1206+
},
1207+
{
1208+
"revision": 7,
1209+
"date": "2026-10-29",
1210+
"change": "fixtures.requires[2] no longer says a persona-created invoice without `owner` lands and reads 404 to its creator: the showcase contributor set now carries the ownership check on insert as well as update (invoice_owner_on_create beside invoice_owner_immutable, examples/app-showcase/src/security/permission-sets.ts), so such a create is refused 403 PERMISSION_DENIED with nothing written. The requirement that the persona own the invoice is unchanged",
1211+
"ref": "claude/harden-invoice-owner-check-on-create"
12061212
}
12071213
]
12081214
},

‎docs/qa/platform-checklist/areas/search.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
{
4646
"step": 5,
4747
"call": "give the member a row of their OWN: as the member, POST /api/v1/data/showcase_invoice with `owner` set to the member's OWN email (the step-1 address) — contributors hold allowCreate",
48-
"expect": "2xx, and that row IS visible to them on a re-read — turning the subset from empty into a proper non-empty one. This is what makes the restriction provably SUBTRACTIVE rather than a blackout; without it, zero-hits and no-access are indistinguishable. ⚠ The body MUST carry `owner: '<the member's email>'`: invoice_own_rows narrows SELECT to owner == current_user.email, and `owner` is a plain text field with no default (examples/app-showcase/src/data/objects/invoice.object.ts), so an invoice the member creates WITHOUT it lands 2xx and is then invisible to its own creator — reading exactly like a blackout (QA run #23166). The invoice number is the required `name` field (there is no `number` field: a body naming `number` answers 400 INVALID_FIELD, QA run #24574); `status` may be omitted (its 'draft' option is default:true). Point the invoice's required `account` at a SEEDED account id whose `status` is not churned (read one from GET /api/v1/data/showcase_account and check it: the first row the list returns can be a churned one, and an invoice against it answers 400 'This account has churned — reactivate it before invoicing.', the invoice object's own validation rule, QA run #26456), never at one a consuming item later deletes: a required lookup turns set_null into restrict, so that delete answers 409 DELETE_RESTRICTED (records-forms.crud-roundtrip met it in QA run #21056).",
48+
"expect": "2xx, and that row IS visible to them on a re-read — turning the subset from empty into a proper non-empty one. This is what makes the restriction provably SUBTRACTIVE rather than a blackout; without it, zero-hits and no-access are indistinguishable. ⚠ The body MUST carry `owner: '<the member's email>'`: invoice_own_rows narrows SELECT to owner == current_user.email, and `owner` is a plain text field with no default (examples/app-showcase/src/data/objects/invoice.object.ts), and the create-time ownership check `invoice_owner_on_create` (permission-sets.ts, the insert twin of `invoice_owner_immutable`) refuses a member create whose `owner` is absent or names anyone else with 403 PERMISSION_DENIED, nothing written. The invoice number is the required `name` field (there is no `number` field: a body naming `number` answers 400 INVALID_FIELD, QA run #24574); `status` may be omitted (its 'draft' option is default:true). Point the invoice's required `account` at a SEEDED account id whose `status` is not churned (read one from GET /api/v1/data/showcase_account and check it: the first row the list returns can be a churned one, and an invoice against it answers 400 'This account has churned — reactivate it before invoicing.', the invoice object's own validation rule, QA run #26456), never at one a consuming item later deletes: a required lookup turns set_null into restrict, so that delete answers 409 DELETE_RESTRICTED (records-forms.crud-roundtrip met it in QA run #21056).",
4949
"source": "ContributorPermissionSet.objects.showcase_invoice.allowCreate === true (permission-sets.ts); the seeded owner emails hold no credential, so borrowing a seeded owner is not an option — this is why the own-row is CREATED"
5050
}
5151
],

‎examples/app-showcase/src/security/permission-sets.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,25 @@ export const ContributorPermissionSet = definePermissionSet({
117117
check: 'owner == current_user.email',
118118
enabled: true,
119119
},
120+
// [ADR-0058 D4] The same ownership CHECK, on INSERT. The rule above is
121+
// `operation: 'update'`, so it does not apply to a create, and a `select`
122+
// policy's `using` never stands in as an insert check (only an applicable
123+
// `insert`/`all` policy's would). This rule keeps a contributor's create
124+
// inside their own read scope: the new invoice must be owned by the caller.
125+
// Clone is a create, so it is judged here too. `owner` is a plain text field
126+
// with no default, so a contributor's create names their own email as owner.
127+
// A separate `insert` policy rather than widening the rule above to `all`:
128+
// `all` would also make a check-only policy applicable to select and delete.
129+
{
130+
name: 'invoice_owner_on_create',
131+
label: 'Invoice Created Owned By Its Creator',
132+
description:
133+
'A contributor can only create an invoice owned by themselves (write-time CHECK on insert, ADR-0058 D4).',
134+
object: 'showcase_invoice',
135+
operation: 'insert' as const,
136+
check: 'owner == current_user.email',
137+
enabled: true,
138+
},
120139
],
121140
});
122141

‎packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
// their lines. `showcase_contributor` scopes invoice SELECT to
77
// `owner == current_user.email` (`invoice_own_rows`) and refuses an UPDATE whose
88
// post-image moves the owner off the caller (`invoice_owner_immutable`,
9-
// ADR-0058 D4). `showcase_invoice_line` is `controlled_by_parent`, so a line is
9+
// ADR-0058 D4), and the same check on INSERT refuses a create — clone included —
10+
// whose new row is owned by anyone but the caller (`invoice_owner_on_create`).
11+
// `showcase_invoice_line` is `controlled_by_parent`, so a line is
1012
// read and written only through its master invoice.
1113
//
1214
// This boots the REAL showcase app with its own `isDefault` baseline and grants
@@ -91,10 +93,20 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut
9193
expect(line, `a seed line under invoice ${invoiceId} must exist`).toBeTruthy();
9294
return line as { id: string; quantity: number };
9395
};
96+
/** A seeded account that is not churned — an invoice against a churned one is refused 400. */
97+
const activeAccount = async (): Promise<string> => {
98+
const rows = (await ql.find('showcase_account', { context: SYS })) as Array<{ id: string; status?: string }>;
99+
const acct = rows.find((a) => a.status !== 'churned');
100+
expect(acct?.id, 'a non-churned seed account must exist').toBeTruthy();
101+
return acct!.id;
102+
};
103+
const countNamed = async (name: string): Promise<number> =>
104+
((await ql.find('showcase_invoice', { where: { name }, context: SYS })) ?? []).length;
105+
let adminToken = '';
94106

95107
beforeAll(async () => {
96108
stack = await bootShowcase({ security: showcaseAppDefaultSecurity() });
97-
await stack.signIn();
109+
adminToken = await stack.signIn();
98110
ql = await stack.kernel.getServiceAsync<any>('objectql');
99111

100112
const set = await catalogPermissionSet(ql, SET);
@@ -241,6 +253,73 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut
241253
expect(((await r.json()) as any)?.code).toBe('PERMISSION_DENIED');
242254
expect((await invoice(p.ownUnpaidInvoice)).owner, 'owner unchanged').toBe(p.email);
243255
});
256+
257+
// `invoice_owner_on_create` — the update-time ownership check above, on
258+
// insert. Both halves per holder shape: the admitted create proves the
259+
// refusal is the row check, not a missing object grant.
260+
it('creates an invoice it owns (create-time check admits)', async () => {
261+
const r = await stack.apiAs(tok(), 'POST', '/data/showcase_invoice', {
262+
name: `INV-OWN-${p.route}`,
263+
owner: p.email,
264+
account: await activeAccount(),
265+
});
266+
expect(r.status, 'own-owner create').toBe(201);
267+
const id = String(idOf(await r.json()));
268+
const back = await stack.apiAs(tok(), 'GET', `/data/showcase_invoice/${id}`);
269+
expect(back.status, 'the creator reads its new invoice').toBe(200);
270+
});
271+
272+
it('cannot create an invoice it does not own — foreign or absent owner (create-time check)', async () => {
273+
const name = `INV-FOREIGN-${p.route}`;
274+
const r = await stack.apiAs(tok(), 'POST', '/data/showcase_invoice', {
275+
name,
276+
owner: 'grace@example.com',
277+
account: await activeAccount(),
278+
});
279+
expect(r.status).toBe(403);
280+
expect(((await r.json()) as any)?.code).toBe('PERMISSION_DENIED');
281+
expect(await countNamed(name), 'nothing written').toBe(0);
282+
283+
// `owner` is a plain text field with no default: a create that omits it
284+
// writes a row nobody owns, which is outside the caller's scope too.
285+
const unowned = `INV-UNOWNED-${p.route}`;
286+
const r2 = await stack.apiAs(tok(), 'POST', '/data/showcase_invoice', {
287+
name: unowned,
288+
account: await activeAccount(),
289+
});
290+
expect(r2.status, 'absent-owner create').toBe(403);
291+
expect(((await r2.json()) as any)?.code).toBe('PERMISSION_DENIED');
292+
expect(await countNamed(unowned), 'nothing written').toBe(0);
293+
});
294+
295+
it("cannot clone an invoice into someone else's ownership; a same-owner clone is admitted", async () => {
296+
const own = await invoice(p.ownUnpaidInvoice);
297+
const foreignName = `INV-CLONE-FOREIGN-${p.route}`;
298+
const refused = await stack.apiAs(tok(), 'POST', `/data/showcase_invoice/${own.id}/clone`, {
299+
overrides: { name: foreignName, owner: 'grace@example.com' },
300+
});
301+
expect(refused.status, 'foreign-owner clone').toBe(403);
302+
expect(((await refused.json()) as any)?.code).toBe('PERMISSION_DENIED');
303+
expect(await countNamed(foreignName), 'nothing written').toBe(0);
304+
305+
const ownName = `INV-CLONE-OWN-${p.route}`;
306+
const admitted = await stack.apiAs(tok(), 'POST', `/data/showcase_invoice/${own.id}/clone`, {
307+
overrides: { name: ownName },
308+
});
309+
expect(admitted.status, 'same-owner clone').toBe(201);
310+
expect(await countNamed(ownName)).toBe(1);
311+
});
244312
});
245313
}
314+
315+
it('the create-time check does not bind the admin: an admin creates an invoice for any owner', async () => {
316+
const name = 'INV-ADMIN-FOR-GRACE';
317+
const r = await stack.apiAs(adminToken, 'POST', '/data/showcase_invoice', {
318+
name,
319+
owner: 'grace@example.com',
320+
account: await activeAccount(),
321+
});
322+
expect(r.status).toBe(201);
323+
expect(await countNamed(name)).toBe(1);
324+
});
246325
});

0 commit comments

Comments
 (0)