From cbd3199d3cc941ff77dd50ed598e69f3f3624bad Mon Sep 17 00:00:00 2001 From: Rupesh Kumar Date: Wed, 16 Sep 2026 10:56:33 +0545 Subject: [PATCH] feat(access): implement client.access.check API and AccessDecision domain types (closes #495) - Expose strongly typed AccessResource on GuildPassClient under client.access - Introduce AccessCheckRequest and canonical AccessDecision types supporting Stellar accounts - Delegate policy evaluation solely to GuildPass Core via HttpTransport - Represent 403 access denial as a normal decision rather than throwing - Preserve typed SDK errors for network, timeout, and API failures - Add comprehensive Vitest test suite covering allowed, denied, and error scenarios --- src/client/index.ts | 17 ++ src/index.ts | 1 + src/resources/access.ts | 136 ++++++++++++++ src/resources/index.ts | 1 + src/types/access.ts | 72 ++++++++ src/types/index.ts | 1 + tests/resources/access.test.ts | 318 +++++++++++++++++++++++++++++++++ 7 files changed, 546 insertions(+) create mode 100644 src/resources/access.ts create mode 100644 src/resources/index.ts create mode 100644 src/types/access.ts create mode 100644 tests/resources/access.test.ts diff --git a/src/client/index.ts b/src/client/index.ts index 99f8564..e60e7ad 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,13 +1,22 @@ import type { ValidatedConfig } from "../config/index.js"; import { parseConfig, type GuildPassClientOptions } from "../config/index.js"; +import { HttpTransport } from "../transport/HttpTransport.js"; +import { AccessResource } from "../resources/access.js"; export type { GuildPassClientOptions } from "../config/index.js"; export class GuildPassClient { private readonly config: ValidatedConfig; + private readonly transport: HttpTransport; + public readonly access: AccessResource; constructor(options: GuildPassClientOptions) { this.config = parseConfig(options); + this.transport = new HttpTransport({ + baseUrl: this.config.baseUrl, + defaultTimeoutMs: this.config.timeoutMs, + }); + this.access = new AccessResource(this.transport, this.config.headers); } /** @@ -38,4 +47,12 @@ export class GuildPassClient { get _internalConfig(): ValidatedConfig { return this.config; } + + /** + * Get the internal transport instance. + * This is exposed for internal SDK or testing use only. + */ + get _internalTransport(): HttpTransport { + return this.transport; + } } diff --git a/src/index.ts b/src/index.ts index 69e9848..a1ea516 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,3 +18,4 @@ export * from "./transport/index.js"; export * from "./errors/index.js"; export * from "./capabilities/index.js"; export * from "./retry/index.js"; +export * from "./resources/index.js"; diff --git a/src/resources/access.ts b/src/resources/access.ts new file mode 100644 index 0000000..235976d --- /dev/null +++ b/src/resources/access.ts @@ -0,0 +1,136 @@ +import { HttpTransport } from "../transport/HttpTransport.js"; +import { HttpError } from "../errors/index.js"; +import type { AccessCheckRequest, AccessDecision } from "../types/access.js"; + +/** + * Resource client for evaluating access control decisions with GuildPass Core. + */ +export class AccessResource { + constructor( + private readonly transport: HttpTransport, + private readonly defaultHeaders: Readonly> = {}, + ) {} + + /** + * Evaluates whether an account is authorized to access a given resource or perform an action within a guild. + * + * Note: Policy evaluation remains the sole responsibility of GuildPass Core. + * The SDK transmits the parameters, normalizes the structured decision, and handles transport errors. + * + * @param request - The strongly typed access evaluation query. + * @returns Structured AccessDecision indicating allowed status and explainable reason. + * @throws {HttpError} On API failures (4xx/5xx other than access denial). + * @throws {NetworkError} On connection or DNS failures. + * @throws {TimeoutError} When request exceeds configured timeout. + */ + public async check(request: AccessCheckRequest): Promise { + if (!request || typeof request !== "object") { + throw new Error("AccessCheckRequest must be an object"); + } + + if (!request.guildId || typeof request.guildId !== "string" || !request.guildId.trim()) { + throw new Error("guildId is required and must be a non-empty string"); + } + + if (!request.account || typeof request.account !== "string" || !request.account.trim()) { + throw new Error("account is required and must be a non-empty string"); + } + + const payload: Record = { + guildId: request.guildId.trim(), + account: request.account.trim(), + }; + + if (request.resource !== undefined) { + payload.resource = request.resource; + } + + if (request.action !== undefined) { + payload.action = request.action; + } + + if (request.context !== undefined) { + payload.context = request.context; + } + + try { + const response = await this.transport.request({ + method: "POST", + path: "/access/check", + headers: { ...this.defaultHeaders }, + body: payload, + }); + + // Handle both direct AccessDecision and { decision: AccessDecision } Core response formats + const decision: any = + response && typeof response === "object" && "decision" in response + ? (response as any).decision + : response; + + return { + allowed: Boolean(decision?.allowed), + ...(decision?.reason ? { reason: String(decision.reason) } : {}), + ...(decision?.guildId + ? { guildId: String(decision.guildId) } + : { guildId: request.guildId.trim() }), + ...(decision?.account + ? { account: String(decision.account) } + : { account: request.account.trim() }), + ...(decision?.resource + ? { resource: String(decision.resource) } + : request.resource !== undefined + ? { resource: request.resource } + : {}), + ...(decision?.action + ? { action: String(decision.action) } + : request.action !== undefined + ? { action: request.action } + : {}), + ...(decision?.metadata && typeof decision.metadata === "object" + ? { metadata: decision.metadata } + : {}), + }; + } catch (error: unknown) { + // Acceptance criteria: + // "Denied access is represented as a normal decision where appropriate rather than always throwing." + if (error instanceof HttpError && error.status === 403) { + const meta = error.metadata as Record | undefined; + let reason = "access_denied"; + let decisionMetadata: Record | undefined; + + if (meta && typeof meta === "object") { + if (typeof meta.reason === "string") { + reason = meta.reason; + } else if (typeof meta.message === "string") { + reason = meta.message; + } else if (typeof meta.error === "string") { + reason = meta.error; + } + + if (typeof meta.metadata === "object" && meta.metadata !== null) { + decisionMetadata = meta.metadata as Record; + } else { + const { reason: _r, message: _m, error: _e, status: _s, allowed: _a, ...rest } = meta; + if (Object.keys(rest).length > 0) { + decisionMetadata = rest; + } + } + } + + return { + allowed: false, + reason, + guildId: request.guildId.trim(), + account: request.account.trim(), + ...(request.resource !== undefined ? { resource: request.resource } : {}), + ...(request.action !== undefined ? { action: request.action } : {}), + ...(decisionMetadata ? { metadata: decisionMetadata } : {}), + }; + } + + // Acceptance criteria: + // "Transport/API failures still use typed SDK errors." + throw error; + } + } +} diff --git a/src/resources/index.ts b/src/resources/index.ts new file mode 100644 index 0000000..655094d --- /dev/null +++ b/src/resources/index.ts @@ -0,0 +1 @@ +export * from "./access.js"; diff --git a/src/types/access.ts b/src/types/access.ts new file mode 100644 index 0000000..028b91b --- /dev/null +++ b/src/types/access.ts @@ -0,0 +1,72 @@ +import type { StellarAccountId } from "../stellar/accountId.js"; + +/** + * Parameters for performing an access evaluation check against GuildPass Core. + */ +export interface AccessCheckRequest { + /** + * The identifier of the guild whose access policy is being evaluated. + */ + guildId: string; + + /** + * The subject account identifier (Stellar address or account identifier). + */ + account: string | StellarAccountId; + + /** + * The specific resource being accessed (optional). + */ + resource?: string; + + /** + * The action the account attempts to perform on the resource (optional). + */ + action?: string; + + /** + * Additional contextual attributes passed for policy evaluation (optional). + */ + context?: Record; +} + +/** + * Structured result of an access evaluation check. + */ +export interface AccessDecision { + /** + * Whether access is granted. + */ + allowed: boolean; + + /** + * Explanation or reason code explaining why access was granted or denied + * (e.g. "required_pass_owned", "pass_expired", "insufficient_tier"). + */ + reason?: string; + + /** + * Guild identifier evaluated. + */ + guildId?: string; + + /** + * Subject account identifier evaluated. + */ + account?: string; + + /** + * Target resource evaluated, if specified. + */ + resource?: string; + + /** + * Target action evaluated, if specified. + */ + action?: string; + + /** + * Optional metadata or diagnostic attributes returned by Core. + */ + metadata?: Record; +} diff --git a/src/types/index.ts b/src/types/index.ts index 4197440..7aec6b2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,2 @@ export * from "./pagination.js"; +export * from "./access.js"; diff --git a/tests/resources/access.test.ts b/tests/resources/access.test.ts new file mode 100644 index 0000000..41db97b --- /dev/null +++ b/tests/resources/access.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { GuildPassClient } from "../../src/client/index.js"; +import { AccessResource } from "../../src/resources/access.js"; +import { HttpTransport } from "../../src/transport/HttpTransport.js"; +import { parseStellarAccountId } from "../../src/stellar/accountId.js"; +import { HttpError, NetworkError, TimeoutError } from "../../src/errors/index.js"; + +const originalFetch = global.fetch; + +describe("AccessResource & client.access.check", () => { + const mockBaseUrl = "https://api.guildpass.example"; + const validStellarAccount = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + let client: GuildPassClient; + + beforeEach(() => { + client = new GuildPassClient({ + baseUrl: mockBaseUrl, + headers: { + "x-api-key": "gp_test_secret_key", + }, + }); + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("API shape & initialization", () => { + it("exposes client.access as an instance of AccessResource", () => { + expect(client.access).toBeDefined(); + expect(client.access).toBeInstanceOf(AccessResource); + expect(typeof client.access.check).toBe("function"); + }); + }); + + describe("Allowed access scenarios", () => { + it("returns an AccessDecision with allowed: true and reason code", async () => { + const mockResponseBody = { + allowed: true, + reason: "required_pass_owned", + guildId: "guild_alpha_123", + account: validStellarAccount, + resource: "vault_channels", + action: "read", + }; + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify(mockResponseBody), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const decision = await client.access.check({ + guildId: "guild_alpha_123", + account: validStellarAccount, + resource: "vault_channels", + action: "read", + }); + + expect(decision).toEqual({ + allowed: true, + reason: "required_pass_owned", + guildId: "guild_alpha_123", + account: validStellarAccount, + resource: "vault_channels", + action: "read", + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledInit] = vi.mocked(global.fetch).mock.calls[0]; + expect(calledUrl).toBe("https://api.guildpass.example/access/check"); + expect(calledInit?.method).toBe("POST"); + + const parsedPayload = JSON.parse(calledInit?.body as string); + expect(parsedPayload).toEqual({ + guildId: "guild_alpha_123", + account: validStellarAccount, + resource: "vault_channels", + action: "read", + }); + }); + + it("supports typed StellarAccountId instances", async () => { + const stellarId = parseStellarAccountId(validStellarAccount); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + allowed: true, + reason: "stellar_membership_active", + }), + { status: 200 }, + ), + ); + + const decision = await client.access.check({ + guildId: "guild_stellar_dao", + account: stellarId, + }); + + expect(decision.allowed).toBe(true); + expect(decision.reason).toBe("stellar_membership_active"); + expect(decision.account).toBe(validStellarAccount); + }); + + it("handles wrapped Core response format { decision: AccessDecision }", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + decision: { + allowed: true, + reason: "tier_3_pass_holder", + metadata: { expiresAt: 1893456000000 }, + }, + }), + { status: 200 }, + ), + ); + + const decision = await client.access.check({ + guildId: "guild_vip", + account: validStellarAccount, + }); + + expect(decision.allowed).toBe(true); + expect(decision.reason).toBe("tier_3_pass_holder"); + expect(decision.metadata).toEqual({ expiresAt: 1893456000000 }); + }); + }); + + describe("Denied access scenarios", () => { + it("returns normal decision when Core responds 200 with allowed: false", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + allowed: false, + reason: "pass_expired", + }), + { status: 200 }, + ), + ); + + const decision = await client.access.check({ + guildId: "guild_alpha", + account: validStellarAccount, + resource: "restricted_zone", + }); + + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("pass_expired"); + }); + + it("captures 403 Forbidden as a normal denied decision instead of throwing", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + reason: "insufficient_tier", + metadata: { requiredTier: "platinum", currentTier: "bronze" }, + }), + { + status: 403, + statusText: "Forbidden", + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const decision = await client.access.check({ + guildId: "guild_gaming", + account: validStellarAccount, + action: "play_tournament", + }); + + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("insufficient_tier"); + expect(decision.metadata).toEqual({ + requiredTier: "platinum", + currentTier: "bronze", + }); + }); + + it("handles 403 Forbidden with error string or message field", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: "pass_revoked", + }), + { status: 403 }, + ), + ); + + const decision = await client.access.check({ + guildId: "guild_gaming", + account: validStellarAccount, + }); + + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("pass_revoked"); + }); + + it("defaults reason to access_denied on 403 without metadata", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response(null, { status: 403 })); + + const decision = await client.access.check({ + guildId: "guild_gaming", + account: validStellarAccount, + }); + + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("access_denied"); + }); + }); + + describe("API and Transport failures (typed SDK errors)", () => { + it("throws HttpError for 400 Bad Request", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Invalid guild ID format" }), { + status: 400, + }), + ); + + await expect( + client.access.check({ + guildId: "invalid!guild", + account: validStellarAccount, + }), + ).rejects.toThrow(HttpError); + }); + + it("throws HttpError for 500 Internal Server Error", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Database outage" }), { + status: 500, + }), + ); + + await expect( + client.access.check({ + guildId: "guild_alpha", + account: validStellarAccount, + }), + ).rejects.toThrow(HttpError); + }); + + it("throws NetworkError on network disconnection", async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new TypeError("Failed to fetch")); + + await expect( + client.access.check({ + guildId: "guild_alpha", + account: validStellarAccount, + }), + ).rejects.toThrow(NetworkError); + }); + }); + + describe("Input validation & policy evaluation boundary", () => { + it("validates that guildId is provided and non-empty", async () => { + await expect( + client.access.check({ + guildId: "", + account: validStellarAccount, + }), + ).rejects.toThrow("guildId is required and must be a non-empty string"); + }); + + it("validates that account is provided and non-empty", async () => { + await expect( + client.access.check({ + guildId: "guild_123", + account: " ", + }), + ).rejects.toThrow("account is required and must be a non-empty string"); + }); + + it("forwards context and does not evaluate policy locally", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + allowed: true, + reason: "contextual_rule_matched", + }), + { status: 200 }, + ), + ); + + const customContext = { ip: "192.168.1.1", clientVersion: "2.1.0" }; + await client.access.check({ + guildId: "guild_123", + account: validStellarAccount, + context: customContext, + }); + + const calledInit = vi.mocked(global.fetch).mock.calls[0][1]; + const parsedBody = JSON.parse(calledInit?.body as string); + expect(parsedBody.context).toEqual(customContext); + }); + + it("propagates configured client headers to request", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ allowed: true }), { status: 200 }), + ); + + await client.access.check({ + guildId: "guild_123", + account: validStellarAccount, + }); + + const calledInit = vi.mocked(global.fetch).mock.calls[0][1]; + const headers = new Headers(calledInit?.headers); + expect(headers.get("x-api-key")).toBe("gp_test_secret_key"); + }); + }); +});