Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/access-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@corbits/access-tools",
"private": true,
"description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz",
"version": "0.0.2",
"version": "0.0.3",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand All @@ -26,6 +26,9 @@
"hono": "catalog:"
},
"devDependencies": {
"@corbits/agent-directory": "workspace:*",
"@corbits/agent-directory-tools": "workspace:*",
"@corbits/tool-registry-publish": "workspace:*",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
Expand Down
185 changes: 181 additions & 4 deletions packages/access-tools/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AccessForbiddenError,
AccessNotFoundError,
grantAccess,
listGrants,
listPrincipals,
revokeAccess,
type AccessToolClientConfig,
} from "./client";
Expand All @@ -17,13 +19,160 @@ function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig {
};
}

test("a 403 from grantAccess surfaces as AccessForbiddenError with the hub's userMessage", async () => {
function nativePrincipal(id: string) {
return {
id,
kind: "workflow",
refId: `${id}@example.test`,
status: "active",
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
};
}

function nativeGrant(id: string) {
return {
id,
principalId: "prin_2",
resource: "workflow-run:*",
action: "read",
effect: "allow",
origin: "invoker",
conditions: null,
expiresAt: null,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
};
}

test("listPrincipals reads the native page envelope and follows nextCursor", async () => {
const seen: string[] = [];
const fetchImpl = (async (url: string | URL | Request) => {
const raw = String(url);
seen.push(raw);
if (raw.includes("cursor=prin_2")) {
return Response.json({
data: [nativePrincipal("prin_3")],
nextCursor: null,
});
}
return Response.json({
data: [nativePrincipal("prin_1"), nativePrincipal("prin_2")],
nextCursor: "prin_2",
});
}) as unknown as typeof fetch;

const principals = await listPrincipals(testConfig(fetchImpl));
expect(principals.map((p) => p.id)).toEqual(["prin_1", "prin_2", "prin_3"]);
expect(principals[0]).toMatchObject({
kind: "workflow",
refId: "prin_1@example.test",
status: "active",
});
expect(seen).toHaveLength(2);
expect(seen[1]).toContain("cursor=prin_2");
});

test("listGrants passes filters through and reads the native page envelope", async () => {
let seenUrl = "";
const fetchImpl = (async (url: string | URL | Request) => {
seenUrl = String(url);
return Response.json({ data: [nativeGrant("grant_1")], nextCursor: null });
}) as unknown as typeof fetch;

const grants = await listGrants(testConfig(fetchImpl), {
principalId: "prin_2",
resource: "workflow-run:*",
});
expect(seenUrl).toContain(
"/grants?principalId=prin_2&resource=workflow-run%3A*",
);
expect(grants).toHaveLength(1);
expect(grants[0]).toMatchObject({
id: "grant_1",
principalId: "prin_2",
resource: "workflow-run:*",
action: "read",
effect: "allow",
});
});

test("listGrants follows nextCursor so a full page plus one all return", async () => {
const all = Array.from({ length: 51 }, (_, i) =>
nativeGrant(`grant_${i + 1}`),
);
const seen: string[] = [];
const fetchImpl = (async (url: string | URL | Request) => {
const raw = String(url);
seen.push(raw);
if (raw.includes("cursor=page_2")) {
return Response.json({ data: all.slice(50), nextCursor: null });
}
return Response.json({ data: all.slice(0, 50), nextCursor: "page_2" });
}) as unknown as typeof fetch;

const grants = await listGrants(testConfig(fetchImpl), {
principalId: "prin_2",
});
expect(grants).toHaveLength(51);
expect(grants.map((g) => g.id)).toEqual(all.map((g) => g.id));
expect(seen).toHaveLength(2);
expect(seen[0]).toContain("principalId=prin_2");
expect(seen[1]).toContain("cursor=page_2");
});

test("grantAccess posts one native single-action body per action and parses single GrantResponse objects", async () => {
const posted: unknown[] = [];
const fetchImpl = (async (
url: string | URL | Request,
init?: RequestInit,
) => {
expect(String(url)).toBe(
"https://hub.example.com/api/workflow-access/grants",
);
expect(init?.method).toBe("POST");
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
posted.push(body);
return new Response(
JSON.stringify({
...nativeGrant(`grant_${posted.length}`),
action: body["action"],
}),
{ status: 201 },
);
}) as unknown as typeof fetch;

const grants = await grantAccess(testConfig(fetchImpl), {
principalId: "prin_2",
resource: "workflow-run:*",
actions: ["read", "write"],
});
expect(posted).toEqual([
{
principalId: "prin_2",
resource: "workflow-run:*",
action: "read",
effect: "allow",
origin: "invoker",
},
{
principalId: "prin_2",
resource: "workflow-run:*",
action: "write",
effect: "allow",
origin: "invoker",
},
]);
expect(grants.map((g) => g.action)).toEqual(["read", "write"]);
});

test("a 403 with the native error envelope surfaces as AccessForbiddenError with the hub's message", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
error: {
code: "forbidden",
userMessage: "You do not have permission to perform this action",
message: "Not a member of this tenant",
},
}),
{ status: 403 },
Expand All @@ -36,13 +185,20 @@ test("a 403 from grantAccess surfaces as AccessForbiddenError with the hub's use
actions: ["read"],
}),
).rejects.toThrow(AccessForbiddenError);
await expect(
grantAccess(testConfig(fetchImpl), {
principalId: "prin_2",
resource: "workflow-run:*",
actions: ["read"],
}),
).rejects.toThrow("Not a member of this tenant");
});

test("a 404 from revokeAccess surfaces as AccessNotFoundError", async () => {
test("a 404 with the native error envelope from revokeAccess surfaces as AccessNotFoundError", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
error: { code: "not_found", userMessage: "No grant here" },
error: { code: "not_found", message: "Grant not found" },
}),
{ status: 404 },
)) as unknown as typeof fetch;
Expand All @@ -51,3 +207,24 @@ test("a 404 from revokeAccess surfaces as AccessNotFoundError", async () => {
revokeAccess(testConfig(fetchImpl), "grant_missing"),
).rejects.toThrow(AccessNotFoundError);
});

test("revokeAccess treats a native 204 with no body as success", async () => {
const fetchImpl = (async () =>
new Response(null, { status: 204 })) as unknown as typeof fetch;

await revokeAccess(testConfig(fetchImpl), "grant_1");
});

test("a grant body missing principalId is a shape error, never a null-linked grant", async () => {
const { principalId: _link, ...linkless } = nativeGrant("grant_1");
void _link;
const fetchImpl = (async () =>
Response.json({
data: [linkless],
nextCursor: null,
})) as unknown as typeof fetch;

await expect(listGrants(testConfig(fetchImpl), {})).rejects.toThrow(
"did not match the expected shape",
);
});
Loading
Loading