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
17 changes: 17 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}

/**
Expand Down Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
136 changes: 136 additions & 0 deletions src/resources/access.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {},
) {}

/**
* 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<AccessDecision> {
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<string, unknown> = {
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<AccessDecision | { decision: AccessDecision }>({
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<string, unknown> | undefined;
let reason = "access_denied";
let decisionMetadata: Record<string, unknown> | 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<string, unknown>;
} 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;
}
}
}
1 change: 1 addition & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./access.js";
72 changes: 72 additions & 0 deletions src/types/access.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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<string, unknown>;
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./pagination.js";
export * from "./access.js";
Loading
Loading