feat: add the two-team fixture and group permission grants

Two tickets, together because the fixture has no behaviour of its own — the
grant tests are its first real use, and "a grant to team A must not
resolve for B's members" is exactly a two-team assertion.

PUT-1719. Every query resolving team-scoped data has to take the
team as a parameter rather than infer it. One that forgets returns the
right rows against a database holding a single team, so a one-team
fixture does not give weaker coverage — it gives false confidence.

The fixture provides two teams, each with its own master and two
activated seats, plus a user in neither. Each team having its own master
is also what the shipped `max_teams_per_user: 1` requires, so it runs
against the real cap rather than lifting it the way the existing team suites
have to. Seats are activated before use: provisioning leaves
`requires_email_confirmation` set and `requireVerified` rejects them outright,
so an unactivated seat cannot call anything and every authorization assertion
built on one would be vacuous.

PUT-1725. The write half of group grants; the read half already worked, since
`#scanUserGroup` joins `jct_user_group` itself and resolves for whoever is a
member at scan time.

  PermissionStore   resolveGroupId, listGroupMemberUuids,
                    upsertUserGroupPerm, deleteUserGroupPerm,
                    auditUserGroupPerm
  PermissionService grantUserGroupPermission, revokeUserGroupPermission

Both rewrite the permission first, so `fs:/path:mode` collapses to
`fs:<uuid>:mode` and a revoke names the same string the grant wrote; both gate
on `canManagePermission`; both audit into the existing
`audit_user_to_group_permissions`.

The delete is scoped to the issuer, so one issuer's revoke cannot drop
another's identical grant — the PK is (user_id, group_id, permission) and
user_id is the issuer.

Cache invalidation goes through `bumpCacheGenerations` with every member uuid
at once. That already announces to peer regions in a single event, so the
fan-out costs one broadcast rather than one per member.

There is no flat-KV equivalent for group grants, so unlike the user-to-user
path there is no second write to keep consistent and no second invalidation
surface.

Closes PUT-1719 and PUT-1725.
This commit is contained in:
Juan Castro
2026-09-09 10:22:56 -04:00
parent 8a70fa2e5c
commit 9f615cc632
4 changed files with 768 additions and 1 deletions
@@ -0,0 +1,362 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../core/actor';
import {
setupTwoTeams,
type TwoTeams,
} from '../../testFixtures/twoTeams.js';
describe('group grants', () => {
let fx: TwoTeams;
/** A plain user actor, which is what a grant is issued as. */
const actorFor = async (userId: number): Promise<Actor> => {
const user = await fx.env.server.stores.user.getById(userId);
return { user } as unknown as Actor;
};
/** Written directly: these tests are about granting, not about writing. */
const makeFile = async (ownerId: number) => {
const uid = crypto.randomUUID();
const name = `f_${uid.slice(0, 8)}.txt`;
const owner = await fx.env.server.stores.user.getById(ownerId);
const path = `/${owner!.username}/${name}`;
await fx.env.server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
'VALUES (?, ?, ?, ?, ?, ?)',
[
uid,
name,
path,
ownerId,
// `is_dir` is a real boolean on postgres.
fx.env.server.clients.db.booleanValue(false),
Math.floor(Date.now() / 1000),
],
);
return { path, uid };
};
const permissions = () => fx.env.server.services.permission;
const store = () => fx.env.server.stores.permission;
beforeAll(async () => {
fx = await setupTwoTeams();
}, 180_000);
afterAll(async () => {
await fx?.shutdown();
});
// -- the fixture itself -------------------------------------------
it('builds two teams, each with its own owner and two seats', async () => {
expect(fx.a.uid).not.toBe(fx.b.uid);
expect(fx.a.owner.userId).not.toBe(fx.b.owner.userId);
expect(fx.a.seats).toHaveLength(2);
expect(fx.b.seats).toHaveLength(2);
// Distinct throughout, or it cannot tell this team from any.
const ids = [
fx.a.owner.userId,
fx.b.owner.userId,
...fx.a.seats.map((s) => s.userId),
...fx.b.seats.map((s) => s.userId),
fx.outsider.userId,
];
expect(new Set(ids).size).toBe(ids.length);
});
it('runs against the shipped one-team-per-user cap', async () => {
// Lifting the cap here would stop it resembling production.
const cfg = fx.env.server.services.team.config as {
max_teams_per_user?: number;
};
expect(cfg.max_teams_per_user ?? 1).toBe(1);
});
it('seats can actually call the API', async () => {
// An inert token would make every assertion below vacuous.
const res = await fx.call('GET', `/teams/${fx.a.uid}`, fx.a.seats[0].token);
expect(res.status).toBe(200);
});
// -- grants resolve for members, and only members -----------------
it('resolves a grant for every member of the team it was given to', async () => {
const file = await makeFile(fx.outsider.userId);
const permission = `fs:${file.uid}:read`;
await permissions().grantUserGroupPermission(
await actorFor(fx.outsider.userId),
fx.a.uid,
permission,
);
for (const seat of fx.a.seats) {
const rows = await store().readUserGroupPerms(seat.userId, [
permission,
]);
expect(rows, `seat ${seat.username}`).toHaveLength(1);
}
});
it('does not resolve for the other team', async () => {
const file = await makeFile(fx.outsider.userId);
const permission = `fs:${file.uid}:read`;
await permissions().grantUserGroupPermission(
await actorFor(fx.outsider.userId),
fx.a.uid,
permission,
);
// Why the fixture has two: with one, an unscoped query still passes.
for (const seat of fx.b.seats) {
const rows = await store().readUserGroupPerms(seat.userId, [
permission,
]);
expect(rows, `B seat ${seat.username}`).toHaveLength(0);
}
});
it('does not resolve for a user in no team', async () => {
const file = await makeFile(fx.a.owner.userId);
const permission = `fs:${file.uid}:read`;
await permissions().grantUserGroupPermission(
await actorFor(fx.a.owner.userId),
fx.a.uid,
permission,
);
const rows = await store().readUserGroupPerms(fx.outsider.userId, [
permission,
]);
expect(rows).toHaveLength(0);
});
// -- revoke --------------------------------------------------------
it('revoking removes it for every member', async () => {
const file = await makeFile(fx.outsider.userId);
const permission = `fs:${file.uid}:read`;
const issuer = await actorFor(fx.outsider.userId);
await permissions().grantUserGroupPermission(
issuer,
fx.a.uid,
permission,
);
const removed = await permissions().revokeUserGroupPermission(
issuer,
fx.a.uid,
permission,
);
expect(removed).toBe(true);
for (const seat of fx.a.seats) {
expect(
await store().readUserGroupPerms(seat.userId, [permission]),
).toHaveLength(0);
}
});
it('announces the revoke for every member, so their watches get settled', async () => {
const file = await makeFile(fx.outsider.userId);
const permission = `fs:${file.uid}:read`;
const issuer = await actorFor(fx.outsider.userId);
const announced: number[] = [];
const bus = fx.env.server.clients.event;
const listen = ((_k: string, d: { holderUserId: number }) => {
announced.push(d.holderUserId);
}) as never;
bus.on('permission.revoked', listen);
try {
await permissions().grantUserGroupPermission(
issuer,
fx.a.uid,
permission,
);
announced.length = 0;
await permissions().revokeUserGroupPermission(
issuer,
fx.a.uid,
permission,
);
} finally {
bus.off?.('permission.revoked', listen);
}
for (const seat of fx.a.seats) {
expect(announced).toContain(seat.userId);
}
});
it('reports false when there was nothing to revoke', async () => {
const file = await makeFile(fx.outsider.userId);
const removed = await permissions().revokeUserGroupPermission(
await actorFor(fx.outsider.userId),
fx.a.uid,
`fs:${file.uid}:read`,
);
// Matching nothing is not an error, but a caller must be able to tell.
expect(removed).toBe(false);
});
it('one issuer revoking does not drop another issuer identical grant', async () => {
// Revoker owns the file; the rival grant is written directly.
const file = await makeFile(fx.a.owner.userId);
const permission = `fs:${file.uid}:write`;
const groupId = (await store().resolveGroupId(fx.a.uid))!;
// The only shape where the DELETE's issuer scoping decides anything.
await permissions().grantUserGroupPermission(
await actorFor(fx.a.owner.userId),
fx.a.uid,
permission,
);
await store().upsertUserGroupPerm(
groupId,
fx.outsider.userId,
permission,
{},
);
expect(
await store().readUserGroupPerms(fx.a.seats[0].userId, [permission]),
).toHaveLength(2);
await permissions().revokeUserGroupPermission(
await actorFor(fx.a.owner.userId),
fx.a.uid,
permission,
);
// The owner's row is gone; the other issuer's survives.
const left = await store().readUserGroupPerms(fx.a.seats[0].userId, [
permission,
]);
expect(left).toHaveLength(1);
expect(left[0].user_id).toBe(fx.outsider.userId);
});
// -- authorization and shape --------------------------------------
it('refuses a grant the issuer has no authority over', async () => {
const file = await makeFile(fx.b.owner.userId);
await expect(
permissions().grantUserGroupPermission(
await actorFor(fx.outsider.userId),
fx.a.uid,
`fs:${file.uid}:write`,
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('404s on a group that does not exist', async () => {
const file = await makeFile(fx.outsider.userId);
await expect(
permissions().grantUserGroupPermission(
await actorFor(fx.outsider.userId),
'00000000-0000-4000-8000-000000000000',
`fs:${file.uid}:read`,
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('round-trips a path-form permission through grant and revoke', async () => {
const owner = await fx.env.server.stores.user.getById(
fx.outsider.userId,
);
const file = await makeFile(fx.outsider.userId);
const issuer = await actorFor(fx.outsider.userId);
// Both must collapse to the same `fs:<uuid>:` string, or nothing matches.
const pathPerm = `fs:${file.path}:read`;
await permissions().grantUserGroupPermission(
issuer,
fx.a.uid,
pathPerm,
);
expect(owner).toBeTruthy();
const stored = await store().readUserGroupPerms(fx.a.seats[0].userId, [
`fs:${file.uid}:read`,
]);
expect(stored).toHaveLength(1);
expect(
await permissions().revokeUserGroupPermission(
issuer,
fx.a.uid,
pathPerm,
),
).toBe(true);
});
it('audits both the grant and the revoke', async () => {
const file = await makeFile(fx.outsider.userId);
const permission = `fs:${file.uid}:read`;
const issuer = await actorFor(fx.outsider.userId);
await permissions().grantUserGroupPermission(
issuer,
fx.a.uid,
permission,
);
await permissions().revokeUserGroupPermission(
issuer,
fx.a.uid,
permission,
);
const rows = (await fx.env.server.clients.db.read(
'SELECT `action` FROM `audit_user_to_group_permissions` ' +
'WHERE `permission` = ? ORDER BY `id`',
[permission],
)) as { action: string }[];
expect(rows.map((r) => r.action)).toEqual(['grant', 'revoke']);
});
it('stops resolving once the team is soft-deleted', async () => {
const file = await makeFile(fx.outsider.userId);
const permission = `fs:${file.uid}:read`;
await permissions().grantUserGroupPermission(
await actorFor(fx.outsider.userId),
fx.b.uid,
permission,
);
const seat = fx.b.seats[0].userId;
expect(
await store().readUserGroupPerms(seat, [permission]),
).toHaveLength(1);
// Deletion suspends the seats but leaves memberships and grants, and
// the uid stops resolving -- so this access would be unwithdrawable.
await fx.env.server.stores.team.softDelete(fx.b.uid);
expect(
await store().readUserGroupPerms(seat, [permission]),
).toHaveLength(0);
});
});
@@ -885,6 +885,145 @@ export class PermissionService extends PuterService {
if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid);
}
// -- Group grants ---- the write half; `#scanUserGroup` reads them ----
/** Resolved once here so neither grant nor revoke holds SQL. */
async #requireGroupId(groupUid: string): Promise<number> {
const groupId = await this.stores.permission.resolveGroupId(groupUid);
if (groupId === null) {
throw new HttpError(404, `group_does_not_exist: ${groupUid}`, {
legacyCode: 'subject_does_not_exist',
});
}
return groupId;
}
/** Batched: one event for the whole group, not one per member. */
async #bumpGroupCacheGeneration(groupId: number): Promise<void> {
const uuids =
await this.stores.permission.listGroupMemberUuids(groupId);
if (uuids.length === 0) return;
await this.stores.permission.bumpCacheGenerations(
uuids.map((uuid) => `user:${uuid}`),
);
}
async grantUserGroupPermission(
actor: Actor,
groupUid: string,
permission: string,
extra: Record<string, unknown> = {},
meta: GrantMeta = {},
): Promise<void> {
// First: the rewrite decides the row's width and what a revoke matches.
permission = await this.rewritePermission(permission);
if (permission.length > PERMISSION_MAX_LEN) {
throw new HttpError(400, 'permission is too long', {
legacyCode: 'bad_request',
});
}
const groupId = await this.#requireGroupId(groupUid);
if (!(await this.canManagePermission(actor, permission))) {
throw new HttpError(403, `permission_denied: ${permission}`, {
legacyCode: 'permission_denied',
});
}
if (!actor.user?.id) {
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
}
const issuerId = actor.user.id;
await this.stores.permission.upsertUserGroupPerm(
groupId,
issuerId,
permission,
extra,
);
// Off the critical path, but a silent drop makes the log untrustworthy.
this.stores.permission
.auditUserGroupPerm({
group_id: groupId,
issuer_user_id: issuerId,
permission,
action: 'grant',
reason: meta.reason ?? 'granted via PermissionService',
extra: this.#auditActorContext(actor),
})
.catch((err) => {
console.warn(
'[PermissionService] failed to audit user-group grant:',
err,
);
});
await this.#bumpGroupCacheGeneration(groupId);
}
/** Scoped to this issuer's grant; returns whether one was removed. */
async revokeUserGroupPermission(
actor: Actor,
groupUid: string,
permission: string,
meta: GrantMeta = {},
): Promise<boolean> {
// Same rewrite as the grant, or this matches nothing and says it did.
permission = await this.rewritePermission(permission);
const groupId = await this.#requireGroupId(groupUid);
if (!actor.user?.id) {
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
}
const issuerId = actor.user.id;
if (!(await this.canManagePermission(actor, permission))) {
throw new HttpError(403, `permission_denied: ${permission}`, {
legacyCode: 'permission_denied',
});
}
const revoked = await this.stores.permission.deleteUserGroupPerm(
groupId,
issuerId,
permission,
);
this.stores.permission
.auditUserGroupPerm({
group_id: groupId,
issuer_user_id: issuerId,
permission,
action: 'revoke',
reason: meta.reason ?? 'revoked via PermissionService',
extra: this.#auditActorContext(actor),
})
.catch((err) => {
console.warn(
'[PermissionService] failed to audit user-group revoke:',
err,
);
});
// Bumped even when nothing matched: a cached allow must not survive.
await this.#bumpGroupCacheGeneration(groupId);
// Nothing but the membership names the holders, so without this their
// watches outlive the revoke.
if (revoked) {
for (const memberId of await this.stores.permission.listGroupMemberIds(
groupId,
)) {
this.#announceRevoked(memberId, null, permission);
}
}
return revoked;
}
/**
* Remove the grant `actor` issued, or the one named by `opts.issuerUserId`
* when the caller has established authority over another issuer's grant (a
@@ -959,10 +959,13 @@ export class PermissionStore extends PuterStore {
if (permissions.length === 0) return [];
let permClause = permissions.map(() => 'p.permission = ?').join(' OR ');
if (permissions.length > 1) permClause = `(${permClause})`;
// Deletion leaves memberships and grants, so a deleted team would
// otherwise keep resolving access nothing can withdraw.
const rows = await this.clients.db.read(
'SELECT p.permission, p.user_id, p.group_id, p.extra FROM `user_to_group_permissions` p ' +
'JOIN `jct_user_group` ug ON p.group_id = ug.group_id ' +
`WHERE ug.user_id = ? AND ${permClause}`,
'JOIN `group` g ON g.`id` = ug.group_id ' +
`WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`,
[userId, ...permissions],
);
return rows.map((row) =>
@@ -970,6 +973,99 @@ export class PermissionStore extends PuterStore {
);
}
/** Any group, seeded or team: a grant does not care which kind it is. */
async resolveGroupId(groupUid: string): Promise<number | null> {
const rows = (await this.clients.db.read(
'SELECT `id` FROM `group` WHERE `uid` = ? LIMIT 1',
[groupUid],
)) as { id: number }[];
return rows[0]?.id ?? null;
}
/** Whose cached readings a grant to this group invalidates. */
async listGroupMemberUuids(groupId: number): Promise<string[]> {
const rows = (await this.clients.db.read(
'SELECT u.`uuid` FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'WHERE ug.`group_id` = ?',
[groupId],
)) as { uuid: string | null }[];
return rows
.map((r) => r.uuid)
.filter((uuid): uuid is string => Boolean(uuid));
}
/** Whose standing access a grant to this group settles. */
async listGroupMemberIds(groupId: number): Promise<number[]> {
const rows = (await this.clients.db.read(
'SELECT `user_id` FROM `jct_user_group` WHERE `group_id` = ?',
[groupId],
)) as { user_id: number }[];
return rows.map((r) => Number(r.user_id));
}
/** `user_id` is the issuer; `group_id` is who receives it. */
async upsertUserGroupPerm(
groupId: number,
issuerUserId: number,
permission: string,
extra: Record<string, unknown>,
): Promise<void> {
const upsertClause = this.clients.db.upsertClause(
['user_id', 'group_id', 'permission'],
['extra'],
);
await this.clients.db.write(
'INSERT INTO `user_to_group_permissions` (`user_id`, `group_id`, `permission`, `extra`) ' +
`VALUES (?, ?, ?, ?) ${upsertClause}`,
[
issuerUserId,
groupId,
permission,
JSON.stringify(extra),
JSON.stringify(extra),
],
);
}
/** Scoped to the issuer: one issuer's revoke must not drop another's. */
async deleteUserGroupPerm(
groupId: number,
issuerUserId: number,
permission: string,
): Promise<boolean> {
const result = await this.clients.db.write(
'DELETE FROM `user_to_group_permissions` ' +
'WHERE `group_id` = ? AND `user_id` = ? AND `permission` = ?',
[groupId, issuerUserId, permission],
);
return result.anyRowsAffected;
}
async auditUserGroupPerm(
entry: AuditEntry & {
group_id: number;
issuer_user_id: number;
permission: string;
},
): Promise<void> {
await this.clients.db.write(
'INSERT INTO `audit_user_to_group_permissions` (' +
'`user_id`, `user_id_keep`, `group_id`, `group_id_keep`, ' +
'`permission`, `extra`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
entry.issuer_user_id,
entry.issuer_user_id,
entry.group_id,
entry.group_id,
entry.permission,
entry.extra ? JSON.stringify(entry.extra) : null,
entry.action,
entry.reason,
],
);
}
// -- SQL: access token permissions -------------------------------
async hasAccessTokenPerm(
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Two teams side by side, plus a user in neither: an unscoped query still
* returns the right rows when only one exists, so one gives false confidence.
*/
import { v4 as uuidv4 } from 'uuid';
import type { IConfig } from '../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../testUtil.js';
/** An account that can call the API: verified, with a session token. */
export type FixtureUser = {
userId: number;
username: string;
token: string;
};
export type FixtureTeam = {
uid: string;
handle: string;
name: string;
/** Owns the team and pays for it; `org_owned = 0`. */
owner: FixtureUser;
/** Provisioned seats, activated so they can make requests. */
seats: FixtureUser[];
};
export type TwoTeams = {
env: PuterTestEnv;
a: FixtureTeam;
b: FixtureTeam;
/** Signed in, in no team at all. */
outsider: FixtureUser;
call: (
method: string,
path: string,
token: string,
body?: unknown,
) => Promise<Response>;
shutdown: () => Promise<void>;
};
const rand = () => Math.random().toString(36).slice(2, 10);
/** Seats per team. Two is enough to tell "this one" from "all of them". */
const SEATS_PER_TEAM = 2;
/** Own owner per team, so this runs against the real cap. */
export const setupTwoTeams = async (
configOverrides: Partial<IConfig> = {},
): Promise<TwoTeams> => {
const env = await setupPuterTestEnv({
teams_enabled: true,
...configOverrides,
} as IConfig);
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const tokenFor = async (userId: number): Promise<string> => {
const row = await env.server.stores.user.getById(userId);
const { token } = await env.server.services.auth.createSessionToken(
row!,
);
return token;
};
const makeUser = async (): Promise<FixtureUser> => {
const username = `fx_${rand()}`;
const created = (await env.server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
// `requireVerified` rejects an unconfirmed account outright.
email_confirmed: true,
})) as unknown as { id: number };
return {
userId: created.id,
username,
token: await tokenFor(created.id),
};
};
/** Provisioning leaves the seat unconfirmed and mid-password-change. */
const activate = async (username: string): Promise<FixtureUser> => {
const seat = await env.server.stores.user.getByUsername(username);
await env.server.stores.user.update(seat!.id, {
email_confirmed: 1,
requires_email_confirmation: 0,
requires_password_change: 0,
});
return {
userId: seat!.id,
username,
token: await tokenFor(seat!.id),
};
};
const expectOk = async (res: Response, what: string) => {
if (res.status !== 200) {
throw new Error(
`fixture: ${what} failed with ${res.status}: ${await res.text()}`,
);
}
return res;
};
const makeTeam = async (name: string): Promise<FixtureTeam> => {
const owner = await makeUser();
const handle = `ws-${rand()}`;
const res = await expectOk(
await call('POST', '/teams', owner.token, { name, handle }),
`creating ${name}`,
);
const team = (await res.json()) as { uid: string };
const seats: FixtureUser[] = [];
for (let i = 0; i < SEATS_PER_TEAM; i++) {
const username = `st_${rand()}`;
await expectOk(
await call('POST', `/teams/${team.uid}/members`, owner.token, {
username,
email: `${username}@test.local`,
}),
`provisioning into ${name}`,
);
seats.push(await activate(username));
}
return { uid: team.uid, handle, name, owner, seats };
};
const a = await makeTeam('Team A');
const b = await makeTeam('Team B');
const outsider = await makeUser();
return { env, a, b, outsider, call, shutdown: () => env.shutdown() };
};