diff --git a/packages/cli/src/auth/device_flow.ts b/packages/cli/src/auth/device_flow.ts index 68c4d4c..0e6450b 100644 --- a/packages/cli/src/auth/device_flow.ts +++ b/packages/cli/src/auth/device_flow.ts @@ -1,6 +1,7 @@ import { DeviceFlowClient, type OAuthUserInfo } from "@slices/oauth"; import { logger } from "../utils/logger.ts"; import { ConfigManager } from "./config.ts"; +import { checkUserWaitlistAccess, showWaitlistError } from "../utils/waitlist.ts"; const DEFAULT_AIP_BASE_URL = "https://auth.slices.network"; const DEFAULT_CLIENT_ID = "24e77d48-a892-4043-b113-ea241f339397"; @@ -41,6 +42,20 @@ export async function performDeviceFlow( const userInfo: OAuthUserInfo = await deviceClient.getUserInfo(tokenResponse.access_token); + if (!userInfo.did) { + throw new Error("Failed to retrieve user DID from authentication response."); + } + + // Only check waitlist access for production Slices network + if (aipBaseUrl === "https://auth.slices.network") { + const { hasAccess, isOnWaitlist } = await checkUserWaitlistAccess(userInfo.did); + + if (!hasAccess) { + showWaitlistError(userInfo.did, isOnWaitlist); + throw new Error("Access denied: User is not on the invite list"); + } + } + const expiresAt = tokenResponse.expires_in ? Date.now() + (tokenResponse.expires_in * 1000) : undefined; diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 60efa2a..0438f60 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,6 +1,7 @@ import type { AuthProvider } from "@slices/client"; import { AtProtoClient } from "../generated_client.ts"; import { ConfigManager } from "../auth/config.ts"; +import { checkUserWaitlistAccess, showWaitlistError } from "./waitlist.ts"; class DeviceAuthProvider implements AuthProvider { private config: ConfigManager; @@ -42,6 +43,21 @@ export async function createAuthenticatedClient(sliceUri: string, apiUrl = "http throw new Error("Not authenticated. Run 'slices login' first."); } + const authConfig = config.get().auth!; + + if (!authConfig.did) { + throw new Error("Missing user DID in authentication config. Please re-authenticate using 'slices login'."); + } + + // Only check waitlist access for production Slices network + if (apiUrl === "https://api.slices.network") { + const { hasAccess, isOnWaitlist } = await checkUserWaitlistAccess(authConfig.did, apiUrl); + + if (!hasAccess) { + showWaitlistError(authConfig.did, isOnWaitlist); + Deno.exit(1); + } + } // Create simple auth provider that uses stored device flow tokens const authProvider = new DeviceAuthProvider(config); diff --git a/packages/cli/src/utils/waitlist.ts b/packages/cli/src/utils/waitlist.ts new file mode 100644 index 0000000..72bcdf2 --- /dev/null +++ b/packages/cli/src/utils/waitlist.ts @@ -0,0 +1,99 @@ +import { AtProtoClient } from "../generated_client.ts"; +import { logger } from "./logger.ts"; + +const DEFAULT_SLICE_URI = + "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z"; +const DEFAULT_ADMIN_DID = "did:plc:bcgltzqazw5tb6k2g3ttenbj"; + +function getSliceUri(): string { + return Deno.env.get("SLICE_URI") || DEFAULT_SLICE_URI; +} + +function getAdminDid(): string { + return Deno.env.get("ADMIN_DID") || DEFAULT_ADMIN_DID; +} + +export interface WaitlistCheckResult { + hasAccess: boolean; + isOnWaitlist: boolean; +} + +export async function checkUserWaitlistAccess( + userDid: string, + apiUrl = "https://api.slices.network" +): Promise { + try { + const sliceUri = getSliceUri(); + const adminDid = getAdminDid(); + + // Create a public client for checking waitlist status (no auth needed) + const client = new AtProtoClient(apiUrl, sliceUri); + + // Query for invites for this DID - using json field to query the record content + const invitesResult = + await client.network.slices.waitlist.invite.getRecords({ + where: { + did: { eq: adminDid }, + slice: { eq: sliceUri }, + json: { contains: userDid }, + }, + limit: 1, + }); + + // Check if user has a valid invite + if (invitesResult.records && invitesResult.records.length > 0) { + const invite = invitesResult.records[0]; + + // Check if invite has expired + if (invite.value.expiresAt) { + const expiresAt = new Date(invite.value.expiresAt); + const now = new Date(); + if (expiresAt < now) { + return { hasAccess: false, isOnWaitlist: false }; // Invite has expired + } + } + + return { hasAccess: true, isOnWaitlist: false }; // Valid invite found + } + + // Check if user is already on the waitlist - requests are created by the user so record.did is correct + const requestsResult = + await client.network.slices.waitlist.request.getRecords({ + where: { + slice: { eq: sliceUri }, + json: { eq: userDid }, + }, + limit: 1, + }); + + const isOnWaitlist = + requestsResult.records && requestsResult.records.length > 0; + + return { hasAccess: false, isOnWaitlist }; + } catch (error) { + logger.error("Error checking user waitlist access:", error); + return { hasAccess: false, isOnWaitlist: false }; // Default to blocking access on error + } +} + +export function showWaitlistError( + userDid: string, + isOnWaitlist: boolean +): void { + console.error("\nāŒ Access Denied"); + console.error("─".repeat(50)); + + if (isOnWaitlist) { + console.error("You are already on the waitlist for Slices."); + console.error("Please wait for an invitation to be sent to your account."); + } else { + console.error("You need an invitation to use Slices."); + console.error("Please visit https://slices.network to request access."); + } + + console.error(`\nYour DID: ${userDid}`); + console.error("─".repeat(50)); + console.error( + "If you believe this is an error, please DM @slices.network on Bsky.\n" + ); +}