From 6eb4746325e308cada2413bbefe83081c59cdd1c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 16 Jan 2026 17:15:45 -0800 Subject: [PATCH 01/17] More SDKs --- sdks/spec/README.md | 87 ++++ sdks/spec/src/_errors.spec.md | 20 + sdks/spec/src/_utilities.spec.md | 76 +++ sdks/spec/src/apps/admin-app.spec.md | 412 +++++++++++++++++ sdks/spec/src/apps/client-app.spec.md | 346 ++++++++++++++ sdks/spec/src/apps/server-app.spec.md | 265 +++++++++++ .../src/types/auth/oauth-connection.spec.md | 129 ++++++ .../contact-channels/contact-channel.spec.md | 88 ++++ sdks/spec/src/types/payments/customer.spec.md | 279 +++++++++++ sdks/spec/src/types/payments/item.spec.md | 122 +++++ .../src/types/permissions/permission.spec.md | 173 +++++++ sdks/spec/src/types/projects/project.spec.md | 201 ++++++++ sdks/spec/src/types/teams/server-team.spec.md | 84 ++++ sdks/spec/src/types/teams/team.spec.md | 135 ++++++ sdks/spec/src/types/users/base-user.spec.md | 73 +++ .../spec/src/types/users/current-user.spec.md | 435 ++++++++++++++++++ sdks/spec/src/types/users/server-user.spec.md | 268 +++++++++++ 17 files changed, 3193 insertions(+) create mode 100644 sdks/spec/README.md create mode 100644 sdks/spec/src/_errors.spec.md create mode 100644 sdks/spec/src/_utilities.spec.md create mode 100644 sdks/spec/src/apps/admin-app.spec.md create mode 100644 sdks/spec/src/apps/client-app.spec.md create mode 100644 sdks/spec/src/apps/server-app.spec.md create mode 100644 sdks/spec/src/types/auth/oauth-connection.spec.md create mode 100644 sdks/spec/src/types/contact-channels/contact-channel.spec.md create mode 100644 sdks/spec/src/types/payments/customer.spec.md create mode 100644 sdks/spec/src/types/payments/item.spec.md create mode 100644 sdks/spec/src/types/permissions/permission.spec.md create mode 100644 sdks/spec/src/types/projects/project.spec.md create mode 100644 sdks/spec/src/types/teams/server-team.spec.md create mode 100644 sdks/spec/src/types/teams/team.spec.md create mode 100644 sdks/spec/src/types/users/base-user.spec.md create mode 100644 sdks/spec/src/types/users/current-user.spec.md create mode 100644 sdks/spec/src/types/users/server-user.spec.md diff --git a/sdks/spec/README.md b/sdks/spec/README.md new file mode 100644 index 0000000000..150c80a280 --- /dev/null +++ b/sdks/spec/README.md @@ -0,0 +1,87 @@ +# Stack Auth SDK Specification + +This folder contains the specification for generating Stack Auth SDKs in multiple programming languages. + +## Purpose + +The spec files describe the SDK interface and behavior in a language-agnostic way. When given to an AI code generator (like Claude or Cursor), they should produce functionally equivalent SDKs in any target language. + +## Repository Structure + +``` +sdks/ +├── spec/ # This folder - SDK specification +│ ├── README.md +│ ├── _utilities.spec.md # Common patterns (auth, HTTP, tokens) +│ ├── _errors.spec.md # Common error types +│ ├── apps/ +│ │ ├── client-app.spec.md # StackClientApp +│ │ ├── server-app.spec.md # StackServerApp +│ │ └── admin-app.spec.md # StackAdminApp +│ └── types/ +│ ├── users/ +│ │ ├── base-user.spec.md # User base properties +│ │ ├── current-user.spec.md # CurrentUser (authenticated) +│ │ └── server-user.spec.md # ServerUser +│ ├── teams/ +│ │ ├── team.spec.md # Team +│ │ └── server-team.spec.md # ServerTeam +│ ├── auth/ +│ │ └── oauth-connection.spec.md +│ ├── contact-channels/ +│ │ └── contact-channel.spec.md +│ ├── projects/ +│ │ └── project.spec.md +│ ├── permissions/ +│ │ └── permission.spec.md +│ └── payments/ +│ ├── customer.spec.md +│ └── item.spec.md +└── implementations/ # Generated SDKs (by language) + ├── python/ + ├── go/ + └── ... +``` + +## Notation + +The spec files use the following notation: + +| Notation | Meaning | +|----------|---------| +| `[authenticated]` | Include access token, handle 401 refresh | +| `[server-only]` | Requires secretServerKey | +| `[admin-only]` | Requires superSecretAdminKey | +| `[BROWSER-ONLY]` | Requires browser environment | +| `{ field, field }` | Request body (JSON) | +| `"Does not error"` | Function handles errors internally | +| `"Errors: ..."` | Lists possible errors with code/message | + +## Language Adaptation + +The generator should adapt: + +- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go) +- **Async patterns**: Promises (JS), async/await (Python), goroutines (Go) +- **Error handling**: Exceptions vs Result types (language preference) +- **Framework hooks**: For React, add `use*` equivalents to `get*`/`list*` methods + +## Usage + +To generate an SDK: + +1. Provide these spec files to an AI code generator +2. Specify the target language and any framework requirements +3. The generator produces implementation code in `sdks/implementations//` + +Example prompt for Python: +``` +Generate a Python SDK from the Stack Auth specification in sdks/spec/. +Use snake_case naming, async/await with httpx, and raise exceptions for errors. +Output to sdks/implementations/python/ +``` + +Example prompt for React: +``` +All get* and list* functions should have a use* hook equivalent. +``` diff --git a/sdks/spec/src/_errors.spec.md b/sdks/spec/src/_errors.spec.md new file mode 100644 index 0000000000..a8fd12b9ad --- /dev/null +++ b/sdks/spec/src/_errors.spec.md @@ -0,0 +1,20 @@ +# Common Errors + +Errors used by many functions. Function-specific errors are defined inline. + + +## VerificationCodeError + +code: "verification_code_error" +message: "The verification code is invalid or has expired." + +Used by: verifyEmail, resetPassword, signInWithMagicLink, acceptTeamInvitation, etc. + + +## ApiError + +code: +message: + +Generic wrapper for unexpected API errors. +Properties: code, message, details (optional object) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md new file mode 100644 index 0000000000..fb5783e481 --- /dev/null +++ b/sdks/spec/src/_utilities.spec.md @@ -0,0 +1,76 @@ +# Utilities + +Common patterns referenced by bracketed notation in other spec files. + + +## [authenticated] - Authenticated Request + +Include header: + x-stack-access-token: + +On 401 with code="access_token_expired": do [token-refresh], retry once. +On 401 after retry: treat as unauthenticated. + + +## [token-refresh] - Token Refresh + +POST /auth/sessions/current/refresh +Headers: x-stack-refresh-token: +Route: apps/backend/src/app/api/latest/auth/sessions/current/refresh/route.ts + +On 200: { access_token, refresh_token } - store both +On error: clear tokens, user is signed out + + +## [server-only] - Server Key Required + +Include header: x-stack-secret-server-key: +Only available in StackServerApp and StackAdminApp. + + +## [admin-only] - Admin Key Required + +Include header: x-stack-super-secret-admin-key: +Only available in StackAdminApp. + + +## Base Request Headers + +Always include on every request: + x-stack-project-id: + x-stack-publishable-client-key: + x-stack-client-version: "@" (e.g. "python@1.0.0") + content-type: application/json + + +## Error Response Format + +4xx/5xx responses have body: { code: string, message: string, details?: object } + +Map `code` to error type. Unknown codes create generic ApiError. + + +## Token Storage + +Store access_token and refresh_token. Strategy from constructor: + +"cookie": + Browser cookies: "stack-refresh-{projectId}", "stack-access" + Options: Secure=true in production, SameSite=Lax + +"memory": + Runtime variable, lost on restart + +RequestLike object: + Read x-stack-auth header (JSON: { accessToken, refreshToken }) + For server-side request handling + + +## Naming Conventions + +SDK uses language-appropriate naming: + - JS/TS: camelCase (displayName, getUser) + - Python: snake_case (display_name, get_user) + - Go: PascalCase exports (DisplayName, GetUser) + +API always uses snake_case in JSON. diff --git a/sdks/spec/src/apps/admin-app.spec.md b/sdks/spec/src/apps/admin-app.spec.md new file mode 100644 index 0000000000..2b9ec03cb1 --- /dev/null +++ b/sdks/spec/src/apps/admin-app.spec.md @@ -0,0 +1,412 @@ +# StackAdminApp + +Extends StackServerApp with administrative capabilities. Requires superSecretAdminKey. + + +## Constructor + +StackAdminApp(options) + +Extends StackServerApp constructor options with: + +Required: + superSecretAdminKey: string - from Stack Auth dashboard + +Optional: + projectOwnerSession: InternalSession - for internal use only + + +## getProject() + +Returns: AdminProject + +GET /projects/current [admin-only] +Route: apps/backend/src/app/api/latest/projects/current/route.ts + +AdminProject extends Project with full configuration access and update methods. + +Does not error. + + +## Permission Definition Methods + + +### listTeamPermissionDefinitions() + +Returns: AdminTeamPermissionDefinition[] + +GET /team-permission-definitions [admin-only] +Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts + +Does not error. + + +### createTeamPermissionDefinition(options) + +options.id: string - permission identifier (e.g., "read", "admin") +options.description: string? + +Returns: AdminTeamPermission + +POST /team-permission-definitions { id, description } [admin-only] + +Does not error. + + +### updateTeamPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? + +PATCH /team-permission-definitions/{permissionId} { description } [admin-only] + +Does not error. + + +### deleteTeamPermissionDefinition(permissionId) + +permissionId: string + +DELETE /team-permission-definitions/{permissionId} [admin-only] + +Does not error. + + +### listProjectPermissionDefinitions() + +Returns: AdminProjectPermissionDefinition[] + +GET /project-permission-definitions [admin-only] + +Does not error. + + +### createProjectPermissionDefinition(options) + +options.id: string +options.description: string? + +Returns: AdminProjectPermission + +POST /project-permission-definitions { id, description } [admin-only] + +Does not error. + + +### updateProjectPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? + +PATCH /project-permission-definitions/{permissionId} { description } [admin-only] + +Does not error. + + +### deleteProjectPermissionDefinition(permissionId) + +permissionId: string + +DELETE /project-permission-definitions/{permissionId} [admin-only] + +Does not error. + + +## API Key Methods + + +### listInternalApiKeys() + +Returns: InternalApiKey[] + +GET /internal/api-keys [admin-only] + +InternalApiKey has: + id: string + description: string + expiresAt: Date | null + createdAt: Date + isPublishableClientKey: bool + isSecretServerKey: bool + isSuperSecretAdminKey: bool + hasPublishableClientKey: bool + hasSecretServerKey: bool + hasSuperSecretAdminKey: bool + userId: string | null + teamId: string | null + +Does not error. + + +### createInternalApiKey(options) + +options.description: string +options.expiresAt: Date? +options.isPublishableClientKey: bool? +options.isSecretServerKey: bool? +options.isSuperSecretAdminKey: bool? +options.userId: string? +options.teamId: string? + +Returns: InternalApiKeyFirstView + +POST /internal/api-keys { ... } [admin-only] + +InternalApiKeyFirstView extends InternalApiKey with: + publishableClientKey: string | null + secretServerKey: string | null + superSecretAdminKey: string | null + +Does not error. + + +## Email Methods + + +### sendTestEmail(options) + +options.recipientEmail: string +options.emailConfig: EmailConfig + +Returns: Result + +POST /internal/email/test { recipient_email, email_config } [admin-only] + +Sends a test email to verify email configuration. + +Does not error (returns Result). + + +### sendSignInInvitationEmail(email, callbackUrl) + +email: string +callbackUrl: string + +POST /auth/magic-link/send { email, callback_url, type: "sign_in_invitation" } [admin-only] + +Does not error. + + +### listSentEmails() + +Returns: AdminSentEmail[] + +GET /internal/sent-emails [admin-only] + +Does not error. + + +### Email Theme Methods + +listEmailThemes(): AdminEmailTheme[] +createEmailTheme(displayName): { id: string } +updateEmailTheme(id, tsxSource): void + +### Email Template Methods + +listEmailTemplates(): AdminEmailTemplate[] +createEmailTemplate(displayName): { id: string } +updateEmailTemplate(id, tsxSource, themeId): { renderedHtml: string } + +### Email Draft Methods + +listEmailDrafts(): AdminEmailDraft[] +createEmailDraft(options): { id: string } +updateEmailDraft(id, data): void + +### Email Preview + +getEmailPreview(options): string (rendered HTML) + + +## Email Outbox Methods + + +### listOutboxEmails(options?) + +options.status: string? - filter by status +options.simpleStatus: string? - filter by simple status +options.limit: number? +options.cursor: string? + +Returns: { items: AdminEmailOutbox[], nextCursor: string | null } + +GET /internal/email-outbox [admin-only] + +Does not error. + + +### getOutboxEmail(id) + +id: string + +Returns: AdminEmailOutbox + +GET /internal/email-outbox/{id} [admin-only] + +Does not error. + + +### updateOutboxEmail(id, options) + +id: string +options.isPaused: bool? +options.scheduledAtMillis: number? +options.cancel: bool? + +Returns: AdminEmailOutbox + +PATCH /internal/email-outbox/{id} { is_paused, scheduled_at_millis, cancel } [admin-only] + +Does not error. + + +### pauseOutboxEmail(id) + +id: string + +Shorthand for updateOutboxEmail(id, { isPaused: true }) + + +### unpauseOutboxEmail(id) + +id: string + +Shorthand for updateOutboxEmail(id, { isPaused: false }) + + +### cancelOutboxEmail(id) + +id: string + +Shorthand for updateOutboxEmail(id, { cancel: true }) + + +## Webhook Methods + + +### sendTestWebhook(options) + +options.endpointId: string + +Returns: Result + +POST /internal/webhooks/test { endpoint_id } [admin-only] + +Does not error (returns Result). + + +## Payment Methods + + +### setupPayments() + +Returns: { url: string } + +POST /internal/payments/setup [admin-only] + +Returns Stripe onboarding URL. + +Does not error. + + +### getStripeAccountInfo() + +Returns: StripeAccountInfo | null + +GET /internal/payments/stripe-account [admin-only] + +StripeAccountInfo has: + account_id: string + charges_enabled: bool + details_submitted: bool + payouts_enabled: bool + +Does not error. + + +### createStripeWidgetAccountSession() + +Returns: { client_secret: string } + +POST /internal/payments/stripe-widget-session [admin-only] + +For embedded Stripe dashboard components. + +Does not error. + + +### createItemQuantityChange(options) + +Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + +options.itemId: string +options.quantity: number - positive to add, negative to subtract +options.expiresAt: string? - ISO date for expiration +options.description: string? + +POST /internal/items/quantity-changes { ... } [admin-only] + +Does not error. + + +### refundTransaction(options) + +options.type: "subscription" | "one-time-purchase" +options.id: string + +POST /internal/transactions/{type}/{id}/refund [admin-only] + +Does not error. + + +### listTransactions(options?) + +options.cursor: string? +options.limit: number? +options.type: TransactionType? +options.customerType: "user" | "team" | "custom"? + +Returns: { transactions: Transaction[], nextCursor: string | null } + +GET /internal/transactions [admin-only] + +Does not error. + + +## Chat Methods (Email Editor AI) + + +### sendChatMessage(threadId, contextType, messages, abortSignal?) + +threadId: string +contextType: "email-theme" | "email-template" | "email-draft" +messages: Array<{ role: string, content: any }> +abortSignal: AbortSignal? + +Returns: { content: ChatContent } + +POST /internal/chat/send { thread_id, context_type, messages } [admin-only] + +For AI-assisted email editing. + +Does not error. + + +### saveChatMessage(threadId, message) + +POST /internal/chat/messages { thread_id, message } [admin-only] + +Does not error. + + +### listChatMessages(threadId) + +Returns: { messages: Array } + +GET /internal/chat/messages?thread_id={threadId} [admin-only] + +Does not error. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md new file mode 100644 index 0000000000..b005e8377e --- /dev/null +++ b/sdks/spec/src/apps/client-app.spec.md @@ -0,0 +1,346 @@ +# StackClientApp + +The main client-side SDK class. Safe for browser use. + + +## Constructor + +StackClientApp(options) + +Required: + projectId: string - from Stack Auth dashboard + publishableClientKey: string - from Stack Auth dashboard + +Optional: + baseUrl: string | { browser, server } + Default: "https://api.stack-auth.com" + Can specify different URLs for browser vs server environments. + + tokenStore: "cookie" | "memory" | RequestLike + Default: "cookie" + Where to store authentication tokens. + "cookie" requires browser environment. + + urls: object + Override handler URLs. Defaults under "/handler": + signIn: "/handler/sign-in" + signUp: "/handler/sign-up" + afterSignIn: "/" + afterSignUp: "/" + ... see apps/backend for full list + +On construct: prefetch project info (GET /projects/current) unless noAutomaticPrefetch=true. + + +## signInWithOAuth(provider, options?) [BROWSER-ONLY] + +provider: string - e.g. "google", "github", "microsoft" +options.returnTo: string? - URL to redirect after auth completes + +Implementation: +1. Generate 32-char random state string +2. Store state in sessionStorage with key "stack-oauth-{state}" +3. Redirect browser to: /auth/oauth/authorize/{provider} + Query params: state, redirect_uri, after_callback_redirect_url + Route: apps/backend/src/app/api/latest/auth/oauth/authorize/[provider]/route.ts + +Does not return (redirects browser). +Does not error. + + +## signInWithCredential(options) + +options.email: string +options.password: string +options.noRedirect: bool? - if true, don't redirect after success + +POST /auth/password/sign-in { email, password } +Route: apps/backend/src/app/api/latest/auth/password/sign-in/route.ts + +On 200: store tokens { access_token, refresh_token } + redirect to afterSignIn URL (unless noRedirect=true) + +Errors: + EmailPasswordMismatch + code: "email_password_mismatch" + message: "The email and password combination is incorrect." + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## signUpWithCredential(options) + +options.email: string +options.password: string +options.verificationCallbackUrl: string? - URL for email verification link +options.noRedirect: bool? + +POST /auth/password/sign-up { email, password, verification_callback_url } +Route: apps/backend/src/app/api/latest/auth/password/sign-up/route.ts + +On 200: store tokens, redirect to afterSignUp (unless noRedirect=true) + +Errors: + UserWithEmailAlreadyExists + code: "user_email_already_exists" + message: "A user with this email address already exists." + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## signOut(options?) + +options.redirectUrl: string? - where to redirect after sign out + +POST /auth/sessions/current/sign-out [authenticated] + Ignore errors (session may already be invalid) +Clear stored tokens. +Redirect to redirectUrl or afterSignOut URL. + +Does not error. + + +## getUser(options?) + +options.or: "redirect" | "throw" | "return-null" | "anonymous" + Default: "return-null" +options.includeRestricted: bool? + Default: false + Whether to return users who haven't completed onboarding (email verification, etc.) + +Returns: CurrentUser | null + +Implementation: +1. Get tokens from storage +2. If no tokens: + - "redirect": redirect to signIn URL, never returns + - "throw": throw UserNotSignedIn error + - "anonymous": POST /auth/users (creates anonymous user), store tokens, continue + - "return-null": return null +3. GET /users/me [authenticated] + Route: apps/backend/src/app/api/latest/users/me/route.ts +4. On 401: [token-refresh], retry once. If still 401: handle as step 2 +5. On 200: construct CurrentUser object (types/users/current-user.spec.md) +6. If user.isRestricted and not includeRestricted: + - "redirect": redirect to onboarding URL + - otherwise: handle as step 2 + +Errors (only when or="throw"): + UserNotSignedIn + code: "user_not_signed_in" + message: "User is not signed in but getUser was called with { or: 'throw' }." + + +## getProject() + +Returns: Project + +GET /projects/current +Route: apps/backend/src/app/api/latest/projects/current/route.ts + +Construct Project object (types/projects/project.spec.md). + +Does not error. + + +## getAccessToken() + +Returns: string | null + +Get access token from storage. +If expired: [token-refresh]. +Return token string, or null if not authenticated. + +Does not error. + + +## getRefreshToken() + +Returns: string | null + +Get refresh token from storage. +Return token string, or null if not authenticated. + +Does not error. + + +## getAuthHeaders() + +Returns: { "x-stack-auth": string } + +JSON-encode { accessToken, refreshToken } into header value. +For cross-origin authenticated requests. + +Does not error. + + +## sendForgotPasswordEmail(email, options?) + +email: string +options.callbackUrl: string? - URL for password reset link + +POST /auth/password/forgot { email, callback_url } +Route: apps/backend/src/app/api/latest/auth/password/forgot/route.ts + +Errors: + UserNotFound + code: "user_not_found" + message: "No user with this email address was found." + + +## resetPassword(options) + +options.code: string - from password reset email +options.password: string - new password + +POST /auth/password/reset { code, password } +Route: apps/backend/src/app/api/latest/auth/password/reset/route.ts + +Errors: + VerificationCodeError (see _errors.spec.md) + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## sendMagicLinkEmail(email, options?) + +email: string +options.callbackUrl: string? + +Returns: { nonce: string } + +POST /auth/magic-link/send { email, callback_url } +Route: apps/backend/src/app/api/latest/auth/magic-link/send/route.ts + +Errors: + RedirectUrlNotWhitelisted + code: "redirect_url_not_whitelisted" + message: "The callback URL is not in the project's trusted domains list." + + +## signInWithMagicLink(code, options?) + +code: string - from magic link URL +options.noRedirect: bool? + +POST /auth/magic-link/sign-in { code } +Route: apps/backend/src/app/api/latest/auth/magic-link/sign-in/route.ts + +On 200: store tokens + redirect to afterSignIn or afterSignUp based on newUser flag (unless noRedirect) + +Errors: + VerificationCodeError (see _errors.spec.md) + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## signInWithPasskey() [BROWSER-ONLY] + +Implementation: +1. POST /auth/passkey/authenticate/initiate {} + Response: { options_json, code } +2. Replace options_json.rpId with window.location.hostname +3. Call WebAuthn API startAuthentication(options_json) + Requires WebAuthn library (e.g., @simplewebauthn/browser) +4. POST /auth/passkey/authenticate { authentication_response, code } +5. On 200: store tokens, redirect to afterSignIn + +Errors: + PasskeyAuthenticationFailed + code: "passkey_authentication_failed" + message: "Passkey authentication failed. Please try again." + + PasskeyWebAuthnError + code: "passkey_webauthn_error" + message: "WebAuthn error: {errorName}." + errorName comes from the WebAuthn API error. + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## verifyEmail(code) + +code: string - from email verification link + +POST /auth/email-verification/verify { code } +Route: apps/backend/src/app/api/latest/auth/email-verification/verify/route.ts + +Errors: + VerificationCodeError (see _errors.spec.md) + + +## acceptTeamInvitation(code) + +code: string - from team invitation email + +POST /teams/invitations/accept { code } [authenticated] +Route: apps/backend/src/app/api/latest/teams/invitations/accept/route.ts + +Errors: + VerificationCodeError (see _errors.spec.md) + + +## getTeamInvitationDetails(code) + +code: string + +Returns: { teamDisplayName: string } + +POST /teams/invitations/details { code } + +Errors: + VerificationCodeError (see _errors.spec.md) + + +## callOAuthCallback() [BROWSER-ONLY] + +Called on the OAuth callback page to complete the flow. + +Returns: bool - true if successful, false if no callback to handle + +Implementation: +1. Read state and code from URL query params +2. Validate state matches sessionStorage +3. POST /auth/oauth/callback { code, state } +4. On success: store tokens, redirect to afterSignIn/afterSignUp +5. Return true + +Errors: + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## Redirect Methods + +All redirect methods take optional { replace?: bool, noRedirectBack?: bool }. + +redirectToSignIn() - redirect to signIn URL +redirectToSignUp() - redirect to signUp URL +redirectToSignOut() - redirect to signOut URL +redirectToAfterSignIn() - redirect to afterSignIn URL +redirectToAfterSignUp() - redirect to afterSignUp URL +redirectToAfterSignOut() - redirect to afterSignOut URL +redirectToHome() - redirect to home URL +redirectToAccountSettings() - redirect to accountSettings URL +redirectToForgotPassword() - redirect to forgotPassword URL +redirectToPasswordReset() - redirect to passwordReset URL +redirectToEmailVerification() - redirect to emailVerification URL +redirectToOnboarding() - redirect to onboarding URL +redirectToError() - redirect to error URL +redirectToMfa() - redirect to mfa URL +redirectToTeamInvitation() - redirect to teamInvitation URL + +All require browser or framework-specific redirect capability. +Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md new file mode 100644 index 0000000000..dcbeb6d254 --- /dev/null +++ b/sdks/spec/src/apps/server-app.spec.md @@ -0,0 +1,265 @@ +# StackServerApp + +Extends StackClientApp with server-side capabilities. Requires secretServerKey. + + +## Constructor + +StackServerApp(options) + +Extends StackClientApp constructor options with: + +Required: + secretServerKey: string - from Stack Auth dashboard + +The secretServerKey enables server-only operations like listing all users, +creating users, and accessing server metadata. + + +## getUser(id) + +id: string - user ID to look up + +Returns: ServerUser | null + +GET /users/{id} [server-only] +Route: apps/backend/src/app/api/latest/users/[userId]/route.ts + +Construct ServerUser object (types/users/server-user.spec.md). + +Does not error. + + +## getUser(options: { apiKey }) + +options.apiKey: string - API key to authenticate with +options.or: "return-null" | "anonymous"? + +Returns: ServerUser | null + +POST /api-keys/check { api_key } [server-only] +Returns user associated with the API key. + +Does not error. + + +## getUser(options: { from: "convex", ctx }) + +options.from: "convex" +options.ctx: ConvexQueryContext - Convex query context +options.or: "return-null" | "anonymous"? + +Returns: ServerUser | null + +Extract token from Convex context, validate, and return user. +For Convex integration. + +Does not error. + + +## listUsers(options?) + +options.cursor: string? - pagination cursor +options.limit: number? - max results (default 100) +options.orderBy: "signedUpAt"? - sort field +options.desc: bool? - descending order +options.query: string? - search query +options.includeRestricted: bool? - include users who haven't completed onboarding +options.includeAnonymous: bool? - include anonymous users + +Returns: ServerUser[] & { nextCursor: string | null } + +GET /users [server-only] +Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous +Route: apps/backend/src/app/api/latest/users/route.ts + +Construct ServerUser for each item. + +Does not error. + + +## createUser(options) + +options.primaryEmail: string? +options.primaryEmailAuthEnabled: bool? +options.password: string? +options.otpAuthEnabled: bool? +options.displayName: string? +options.primaryEmailVerified: bool? +options.clientMetadata: json? +options.clientReadOnlyMetadata: json? +options.serverMetadata: json? + +Returns: ServerUser + +POST /users { ... } [server-only] +Route: apps/backend/src/app/api/latest/users/route.ts + +Does not error. + + +## getTeam(id) + +id: string - team ID + +Returns: ServerTeam | null + +GET /teams/{id} [server-only] +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Construct ServerTeam object (types/teams/server-team.spec.md). + +Does not error. + + +## getTeam(options: { apiKey }) + +options.apiKey: string - team API key + +Returns: ServerTeam | null + +POST /api-keys/check { api_key } [server-only] +Returns team associated with the API key. + +Does not error. + + +## listTeams() + +Returns: ServerTeam[] + +GET /teams [server-only] +Route: apps/backend/src/app/api/latest/teams/route.ts + +Does not error. + + +## createTeam(options) + +options.displayName: string +options.profileImageUrl: string? +options.creatorUserId: string? - user to add as creator/member + +Returns: ServerTeam + +POST /teams { display_name, profile_image_url, creator_user_id } [server-only] +Route: apps/backend/src/app/api/latest/teams/route.ts + +Does not error. + + +## grantProduct(options) + +Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + +Product identification (one of): + options.productId: string - existing product ID + options.product: InlineProduct - inline product definition + +options.quantity: number? - default 1 + +POST /customers/{type}/{id}/products { product_id | product, quantity } [server-only] +Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts + +Does not error. + + +## sendEmail(options) + +options.to: string | string[] - recipient email(s) +options.subject: string +options.html: string? - HTML body +options.text: string? - plain text body + +POST /emails { to, subject, html, text } [server-only] +Route: apps/backend/src/app/api/latest/emails/route.ts + +Does not error. + + +## getEmailDeliveryStats() + +Returns: EmailDeliveryInfo + +GET /emails/delivery-stats [server-only] +Route: apps/backend/src/app/api/latest/emails/delivery-stats/route.ts + +Returns: { + delivered: number, + bounced: number, + complained: number, + total: number, +} + +Does not error. + + +## createOAuthProvider(options) + +options.userId: string +options.accountId: string +options.providerConfigId: string +options.email: string +options.allowSignIn: bool +options.allowConnectedAccounts: bool + +Returns: Result + +POST /users/{userId}/oauth-providers { ... } [server-only] +Route: apps/backend/src/app/api/latest/users/[userId]/oauth-providers/route.ts + +Errors: + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user for sign-in." + + +## getDataVaultStore(id) + +id: string - data vault store ID + +Returns: DataVaultStore + +GET /data-vault/stores/{id} [server-only] + +DataVaultStore has: + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise + +Does not error. + + +## getItem(options) + +Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + +options.itemId: string + +Returns: ServerItem + +GET /customers/{type}/{id}/items/{itemId} [server-only] +Route: apps/backend/src/app/api/latest/customers/[...]/items/[itemId]/route.ts + +ServerItem has: + id: string + quantity: number + +Does not error. + + +## listProducts(options) + +options: CustomerProductsRequestOptions + +Returns: CustomerProductsList + +GET /customers/{type}/{id}/products [server-only] + +Does not error. diff --git a/sdks/spec/src/types/auth/oauth-connection.spec.md b/sdks/spec/src/types/auth/oauth-connection.spec.md new file mode 100644 index 0000000000..dcbf0e0b85 --- /dev/null +++ b/sdks/spec/src/types/auth/oauth-connection.spec.md @@ -0,0 +1,129 @@ +# OAuthConnection + +A connected OAuth account that can be used to access third-party APIs. + + +## Properties + +id: string + The OAuth provider ID (e.g., "google", "github"). + + +## Methods + + +### getAccessToken() + +Returns: string + +POST /connected-accounts/{id}/access-token {} [authenticated] +Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts + +Returns a fresh OAuth access token for the connected account. +The token is automatically refreshed if expired (if provider supports refresh). + +Errors: + OAuthConnectionTokenExpired + code: "oauth_connection_token_expired" + message: "The OAuth token has expired and cannot be refreshed. Please reconnect." + + +--- + +# OAuthProvider + +An OAuth provider linked to a user's account. + + +## Properties + +id: string + Unique provider link ID. + +type: string + Provider type (e.g., "google", "github", "microsoft"). + +userId: string + The user this provider is linked to. + +accountId: string? + The account ID from the OAuth provider. Optional for client-side. + +email: string? + Email associated with the OAuth account. + +allowSignIn: bool + Whether this provider can be used to sign in. + +allowConnectedAccounts: bool + Whether this provider can be used for connected account access (API access). + + +## Methods + + +### update(options) + +options: { + allowSignIn?: bool, + allowConnectedAccounts?: bool, +} + +Returns: Result + +PATCH /users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated] +Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts + +Errors (in Result): + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user for sign-in." + + +### delete() + +DELETE /users/me/oauth-providers/{id} [authenticated] +Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts + +Does not error. + + +--- + +# ServerOAuthProvider + +Server-side OAuth provider with additional update capabilities. + +Extends: OAuthProvider + +accountId is always present (not optional). + + +## Server-specific Methods + + +### update(options) + +options: { + accountId?: string, + email?: string, + allowSignIn?: bool, + allowConnectedAccounts?: bool, +} + +Returns: Result + +PATCH /users/{userId}/oauth-providers/{id} [server-only] +Body: { account_id, email, allow_sign_in, allow_connected_accounts } + +Errors (in Result): + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user for sign-in." + + +### delete() + +DELETE /users/{userId}/oauth-providers/{id} [server-only] + +Does not error. diff --git a/sdks/spec/src/types/contact-channels/contact-channel.spec.md b/sdks/spec/src/types/contact-channels/contact-channel.spec.md new file mode 100644 index 0000000000..55405ca7ce --- /dev/null +++ b/sdks/spec/src/types/contact-channels/contact-channel.spec.md @@ -0,0 +1,88 @@ +# ContactChannel + +A contact channel (email address) associated with a user. + + +## Properties + +id: string + Unique contact channel identifier. + +value: string + The actual email address. + +type: "email" + Type of contact channel. Currently only "email" is supported. + +isPrimary: bool + Whether this is the user's primary email. + +isVerified: bool + Whether the email has been verified. + +usedForAuth: bool + Whether this email can be used for authentication (magic link, password reset, etc.). + + +## Methods + + +### sendVerificationEmail(options?) + +options.callbackUrl: string? - URL to redirect after verification + +POST /contact-channels/{id}/send-verification-email { callback_url } [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/[id]/send-verification-email/route.ts + +Sends a verification email to this contact channel. + +Does not error. + + +### update(options) + +options: { + value?: string, + usedForAuth?: bool, + isPrimary?: bool, +} + +PATCH /contact-channels/{id} { value, used_for_auth, is_primary } [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts + +Does not error. + + +### delete() + +DELETE /contact-channels/{id} [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts + +Does not error. + + +--- + +# ServerContactChannel + +Server-side contact channel with additional update capabilities. + +Extends: ContactChannel + + +## Server-specific Methods + + +### update(options) + +options: { + value?: string, + usedForAuth?: bool, + isPrimary?: bool, + isVerified?: bool, // Server can directly set verification status +} + +PATCH /contact-channels/{id} [server-only] +Body: { value, used_for_auth, is_primary, is_verified } + +Does not error. diff --git a/sdks/spec/src/types/payments/customer.spec.md b/sdks/spec/src/types/payments/customer.spec.md new file mode 100644 index 0000000000..e2c92c6f17 --- /dev/null +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -0,0 +1,279 @@ +# Customer + +Interface for payment and billing operations. Implemented by CurrentUser and Team. + + +## Properties + +id: string + The customer identifier (user ID or team ID). + + +## Methods + + +### createCheckoutUrl(options) + +options.productId: string - ID of the product to purchase +options.returnUrl: string? - URL to redirect after checkout + +Returns: string (checkout URL) + +POST /customers/{type}/{id}/checkout { product_id, return_url } [authenticated] +Route: apps/backend/src/app/api/latest/customers/[...]/checkout/route.ts + +Returns a Stripe checkout URL for purchasing the product. + +Does not error. + + +### getBilling() + +Returns: CustomerBilling + +GET /customers/{type}/{id}/billing [authenticated] +Route: apps/backend/src/app/api/latest/customers/[...]/billing/route.ts + +CustomerBilling has: + hasCustomer: bool - whether a Stripe customer exists + defaultPaymentMethod: CustomerDefaultPaymentMethod | null + +CustomerDefaultPaymentMethod has: + id: string + brand: string | null (e.g., "visa", "mastercard") + last4: string | null + exp_month: number | null + exp_year: number | null + +Does not error. + + +### createPaymentMethodSetupIntent() + +Returns: CustomerPaymentMethodSetupIntent + +POST /customers/{type}/{id}/payment-method-setup-intent [authenticated] + +CustomerPaymentMethodSetupIntent has: + clientSecret: string - for Stripe.js to confirm setup + stripeAccountId: string - the connected Stripe account + +Does not error. + + +### setDefaultPaymentMethodFromSetupIntent(setupIntentId) + +setupIntentId: string + +Returns: CustomerDefaultPaymentMethod + +POST /customers/{type}/{id}/default-payment-method { setup_intent_id } [authenticated] + +After user completes payment method setup via Stripe.js, +call this to set it as default. + +Does not error. + + +### getItem(itemId) + +itemId: string + +Returns: Item + +GET /customers/{type}/{id}/items/{itemId} [authenticated] + +Item has: + displayName: string + quantity: number - may be negative + nonNegativeQuantity: number - Math.max(0, quantity) + +Does not error. + + +### listItems() + +Returns: Item[] + +GET /customers/{type}/{id}/items [authenticated] + +Does not error. + + +### hasItem(itemId) + +itemId: string + +Returns: bool + +Check if getItem(itemId).quantity > 0. + +Does not error. + + +### getItemQuantity(itemId) + +itemId: string + +Returns: number + +Get getItem(itemId).quantity. + +Does not error. + + +### listProducts(options?) + +options.cursor: string? +options.limit: number? + +Returns: CustomerProductsList + +GET /customers/{type}/{id}/products [authenticated] +Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts + +CustomerProductsList is CustomerProduct[] with: + nextCursor: string | null + +Does not error. + + +### switchSubscription(options) + +options.fromProductId: string - current subscription product ID +options.toProductId: string - target subscription product ID +options.priceId: string? - specific price of target product +options.quantity: number? + +POST /customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated] + +For switching between subscription plans. + +Does not error. + + +--- + +# CustomerProduct + +A product associated with a customer. + + +## Properties + +id: string | null + Product ID, or null for inline products. + +quantity: number + Quantity owned. + +displayName: string + Product display name. + +customerType: "user" | "team" | "custom" + Type of customer this product is for. + +isServerOnly: bool + Whether this product can only be granted server-side. + +stackable: bool + Whether multiple quantities can be owned. + +type: "one_time" | "subscription" + Product type. + +subscription: SubscriptionInfo | null + Subscription details if type is "subscription". + +switchOptions: SwitchOption[]? + Available products to switch to (for subscriptions). + + +## SubscriptionInfo + +currentPeriodEnd: Date | null + When current billing period ends. + +cancelAtPeriodEnd: bool + Whether subscription will cancel at period end. + +isCancelable: bool + Whether subscription can be canceled. + + +## SwitchOption + +productId: string +displayName: string +prices: Price[] + + +--- + +# ServerItem (server-only) + +Server-side item with modification methods. + +Extends: Item + + +## Methods + + +### increaseQuantity(amount) + +amount: number (positive) + +POST /customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] + +Does not error. + + +### decreaseQuantity(amount) + +amount: number (positive) + +POST /customers/{type}/{id}/items/{itemId}/quantity { change: -amount } [server-only] + +Note: Quantity may go negative. Use tryDecreaseQuantity for atomic decrement-if-positive. + +Does not error. + + +### tryDecreaseQuantity(amount) + +amount: number (positive) + +Returns: bool + +POST /customers/{type}/{id}/items/{itemId}/try-decrease { amount } [server-only] + +Returns true if quantity was >= amount and was decreased. +Returns false if quantity would go negative (no change made). + +Useful for pre-paid credits to prevent overdraft. + +Does not error. + + +--- + +# InlineProduct + +For creating products on-the-fly without pre-defining them. + + +## Properties + +displayName: string +type: "one_time" | "subscription" +isServerOnly: bool? +stackable: bool? +prices: InlinePrice[] + + +## InlinePrice + +amount: number (in cents) +currency: string (e.g., "usd") +interval: "month" | "year"? (for subscriptions) diff --git a/sdks/spec/src/types/payments/item.spec.md b/sdks/spec/src/types/payments/item.spec.md new file mode 100644 index 0000000000..b900085d73 --- /dev/null +++ b/sdks/spec/src/types/payments/item.spec.md @@ -0,0 +1,122 @@ +# Item + +A quantifiable item owned by a customer (user or team). +Used for tracking credits, feature flags, or any countable resource. + + +## Properties + +displayName: string + Human-readable name for the item. + +quantity: number + The quantity owned. May be negative (for debt/overdraft scenarios). + +nonNegativeQuantity: number + Convenience property equal to Math.max(0, quantity). + Useful for displaying "available balance" that's never negative. + + +## Usage Examples + +Items are commonly used for: + +1. **Credits/Tokens** + - Pre-paid API credits + - AI tokens + - Message allowances + +2. **Feature Flags** + - quantity > 0 means feature is enabled + - quantity = 0 means feature is disabled + +3. **Usage Limits** + - Track remaining quota + - Prevent overdraft with tryDecreaseQuantity + + +--- + +# ServerItem + +Server-side item with methods to modify quantity. + +Extends: Item + + +## Methods + + +### increaseQuantity(amount) + +amount: number (positive) + +POST /internal/items/quantity-changes { + user_id | team_id | custom_customer_id, + item_id, + quantity: amount +} [server-only] + +Increases the item quantity by the specified amount. + +Does not error. + + +### decreaseQuantity(amount) + +amount: number (positive) + +POST /internal/items/quantity-changes { + user_id | team_id | custom_customer_id, + item_id, + quantity: -amount +} [server-only] + +Decreases the item quantity by the specified amount. +Note: The quantity CAN go negative. If you want to prevent this, +use tryDecreaseQuantity instead. + +Does not error. + + +### tryDecreaseQuantity(amount) + +amount: number (positive) + +Returns: bool + +POST /internal/items/try-decrease { + user_id | team_id | custom_customer_id, + item_id, + amount +} [server-only] + +Atomically tries to decrease the quantity: +- If current quantity >= amount: decreases and returns true +- If current quantity < amount: does nothing and returns false + +This is race-condition safe and ideal for: +- Deducting pre-paid credits +- Consuming limited resources +- Any scenario where overdraft must be prevented + +Does not error. + + +## Example Usage (pseudocode) + +``` +// Granting credits +item = server.getItem({ userId: "...", itemId: "api-credits" }) +await item.increaseQuantity(100) + +// Consuming credits (with overdraft protection) +success = await item.tryDecreaseQuantity(10) +if not success: + throw InsufficientCredits("Not enough credits") + +// Checking balance +item = user.getItem("api-credits") +print(f"Available: {item.nonNegativeQuantity}") +print(f"Actual balance: {item.quantity}") // might be negative +``` diff --git a/sdks/spec/src/types/permissions/permission.spec.md b/sdks/spec/src/types/permissions/permission.spec.md new file mode 100644 index 0000000000..e5b8da1a5a --- /dev/null +++ b/sdks/spec/src/types/permissions/permission.spec.md @@ -0,0 +1,173 @@ +# TeamPermission + +A permission granted to a user within a team. + + +## Properties + +id: string + The permission identifier (e.g., "read", "write", "admin"). + + +--- + +# AdminTeamPermission + +Admin view of a team permission. Same as TeamPermission. + +Extends: TeamPermission + + +--- + +# AdminTeamPermissionDefinition + +Definition of a team permission that can be granted. + + +## Properties + +id: string + Unique permission identifier. + +description: string? + Human-readable description of what this permission allows. + +containedPermissionIds: string[] + List of other permission IDs that are implied by this permission. + For hierarchical permissions (e.g., "admin" contains "write" and "read"). + +isDefaultUserPermission: bool? + Whether this permission is granted by default to new team members. + + +--- + +# ProjectPermission + +A project-level permission granted to a user. + + +## Properties + +id: string + The permission identifier. + + +--- + +# AdminProjectPermission + +Admin view of a project permission. Same as ProjectPermission. + +Extends: ProjectPermission + + +--- + +# AdminProjectPermissionDefinition + +Definition of a project-level permission. + + +## Properties + +id: string + Unique permission identifier. + +description: string? + Human-readable description. + +containedPermissionIds: string[] + List of implied permission IDs. + + +--- + +# Permission Definition CRUD (Admin only) + + +## Team Permission Definitions + +### Create + +createTeamPermissionDefinition(options) + +options.id: string +options.description: string? +options.containedPermissionIds: string[] +options.isDefaultUserPermission: bool? + +POST /team-permission-definitions { id, description, contained_permission_ids } [admin-only] +Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts + + +### Update + +updateTeamPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? +options.containedPermissionIds: string[]? + +PATCH /team-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] + + +### Delete + +deleteTeamPermissionDefinition(permissionId) + +permissionId: string + +DELETE /team-permission-definitions/{permissionId} [admin-only] + + +### List + +listTeamPermissionDefinitions() + +Returns: AdminTeamPermissionDefinition[] + +GET /team-permission-definitions [admin-only] + + +## Project Permission Definitions + +### Create + +createProjectPermissionDefinition(options) + +options.id: string +options.description: string? +options.containedPermissionIds: string[] + +POST /project-permission-definitions { id, description, contained_permission_ids } [admin-only] + + +### Update + +updateProjectPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? +options.containedPermissionIds: string[]? + +PATCH /project-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] + + +### Delete + +deleteProjectPermissionDefinition(permissionId) + +permissionId: string + +DELETE /project-permission-definitions/{permissionId} [admin-only] + + +### List + +listProjectPermissionDefinitions() + +Returns: AdminProjectPermissionDefinition[] + +GET /project-permission-definitions [admin-only] diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md new file mode 100644 index 0000000000..86be310499 --- /dev/null +++ b/sdks/spec/src/types/projects/project.spec.md @@ -0,0 +1,201 @@ +# Project + +Basic project information returned by getProject(). + + +## Properties + +id: string + Unique project identifier. + +displayName: string + Project's display name. + +config: ProjectConfig + Project configuration. See below. + + +--- + +# ProjectConfig + +Client-visible project configuration. + + +## Properties + +signUpEnabled: bool + Whether new user sign-ups are allowed. + +credentialEnabled: bool + Whether email/password authentication is enabled. + +magicLinkEnabled: bool + Whether magic link authentication is enabled. + +passkeyEnabled: bool + Whether passkey authentication is enabled. + +oauthProviders: OAuthProviderConfig[] + List of enabled OAuth providers. + Each has: id: string, type: "google" | "github" | "microsoft" | etc. + +clientTeamCreationEnabled: bool + Whether clients can create teams. + +clientUserDeletionEnabled: bool + Whether clients can delete their own accounts. + + +--- + +# AdminProject + +Full project information with admin capabilities. + +Extends: Project + + +## Additional Properties + +description: string | null + Project description. + +createdAt: Date + When the project was created. + +isProductionMode: bool + Whether project is in production mode. + +ownerTeamId: string | null + The team that owns this project. + +logoUrl: string | null + URL to project logo. + +logoFullUrl: string | null + URL to full-size project logo. + +logoDarkModeUrl: string | null + URL to dark mode logo. + +logoFullDarkModeUrl: string | null + URL to full-size dark mode logo. + +config: AdminProjectConfig + Full project configuration (extends ProjectConfig with sensitive settings). + + +## Methods + + +### update(options) + +options: { + displayName?: string, + description?: string, + isProductionMode?: bool, + logoUrl?: string | null, + logoFullUrl?: string | null, + logoDarkModeUrl?: string | null, + logoFullDarkModeUrl?: string | null, + config?: AdminProjectConfigUpdateOptions, +} + +PATCH /projects/current [admin-only] +Route: apps/backend/src/app/api/latest/projects/current/route.ts + +Does not error. + + +### delete() + +DELETE /projects/current [admin-only] + +Does not error. + + +### getConfig() + +Returns: CompleteConfig + +GET /projects/current/config [admin-only] + +Returns the full normalized project configuration. + +Does not error. + + +### updateConfig(config) + +config: EnvironmentConfigOverride + Use path notation to update nested properties (e.g., { "emails.server.host": "..." }) + Do NOT pass full top-level objects as they will overwrite siblings. + +PATCH /projects/current/config { ...pathUpdates } [admin-only] + +Does not error. + + +### getProductionModeErrors() + +Returns: ProductionModeError[] + +GET /projects/current/production-mode-errors [admin-only] + +Returns a list of issues that would prevent production mode. + +ProductionModeError has: + type: string + message: string + +Does not error. + + +--- + +# AdminProjectConfig + +Extended project configuration with admin-only settings. + +Extends: ProjectConfig + + +## Additional Properties + +domains: DomainConfig[] + Trusted domains configuration. + Each has: domain: string, handlerPath: string + +emailConfig: EmailConfig + Email sending configuration. + Either: { type: "shared" } - use Stack's shared email + Or: { type: "standard", host, port, username, password, senderName, senderEmail } + +allowLocalhost: bool + Whether localhost is allowed (for development). + +createTeamOnSignUp: bool + Whether to create a team for each new user. + +teamCreatorDefaultPermissions: string[] + Default permissions for team creators. + +teamMemberDefaultPermissions: string[] + Default permissions for team members. + +userDefaultPermissions: string[] + Default project-level permissions for users. + +oauthAccountMergeStrategy: "link" | "prevent" + How to handle OAuth accounts with existing emails. + +allowUserApiKeys: bool + Whether users can create API keys. + +allowTeamApiKeys: bool + Whether teams can create API keys. + +oauthProviders: AdminOAuthProviderConfig[] + Full OAuth provider configs including secrets. + Each has: id, type, clientId, clientSecret, and provider-specific fields. diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md new file mode 100644 index 0000000000..a7571ef90a --- /dev/null +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -0,0 +1,84 @@ +# ServerTeam + +Server-side team with additional management capabilities. + +Extends: Team (team.spec.md) + + +## Additional Properties + +createdAt: Date + When the team was created. + +serverMetadata: json + Server-only metadata, not visible to client. + + +## Server-specific Methods + + +### update(options) + +options: { + displayName?: string, + profileImageUrl?: string | null, + clientMetadata?: json, + clientReadOnlyMetadata?: json, + serverMetadata?: json, +} + +PATCH /teams/{teamId} [server-only] +Body: { display_name, profile_image_url, client_metadata, client_read_only_metadata, server_metadata } +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Does not error. + + +### listUsers() + +Returns: ServerTeamUser[] + +GET /teams/{teamId}/users [server-only] +Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts + +ServerTeamUser extends ServerUser with: + teamProfile: ServerTeamMemberProfile + +Does not error. + + +### addUser(userId) + +userId: string + +POST /teams/{teamId}/users { user_id } [server-only] + +Directly adds a user to the team without invitation. + +Does not error. + + +### removeUser(userId) + +userId: string + +DELETE /teams/{teamId}/users/{userId} [server-only] + +Does not error. + + +### inviteUser(options) + +options.email: string +options.callbackUrl: string? + +POST /teams/{teamId}/invitations { email, callback_url } [server-only] + +Does not error. + + +### delete() + +DELETE /teams/{teamId} [server-only] + +Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md new file mode 100644 index 0000000000..5db8f0bd60 --- /dev/null +++ b/sdks/spec/src/types/teams/team.spec.md @@ -0,0 +1,135 @@ +# Team + +A team/organization that users can belong to. + + +## Properties + +id: string + Unique team identifier. + +displayName: string + Team's display name. + +profileImageUrl: string | null + URL to team's profile image. + +clientMetadata: json + Team-writable metadata, visible to client and server. + +clientReadOnlyMetadata: json + Server-writable metadata, visible to client but not writable by client. + + +## Methods + + +### update(options) + +options: { + displayName?: string, + profileImageUrl?: string | null, + clientMetadata?: json, +} + +PATCH /teams/{teamId} [authenticated] +Body: { display_name, profile_image_url, client_metadata } +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Does not error. + + +### delete() + +DELETE /teams/{teamId} [authenticated] +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Does not error. + + +### inviteUser(options) + +options.email: string +options.callbackUrl: string? + +POST /teams/{teamId}/invitations { email, callback_url } [authenticated] + +Sends invitation email to the specified address. + +Does not error. + + +### listUsers() + +Returns: TeamUser[] + +GET /teams/{teamId}/users [authenticated] +Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts + +TeamUser has: + id: string + teamProfile: TeamMemberProfile + +TeamMemberProfile has: + displayName: string | null + profileImageUrl: string | null + +Does not error. + + +### listInvitations() + +Returns: TeamInvitation[] + +GET /teams/{teamId}/invitations [authenticated] + +TeamInvitation has: + id: string + recipientEmail: string | null + expiresAt: Date + revoke(): Promise + +Does not error. + + +### createApiKey(options) + +options.description: string +options.expiresAt: Date? +options.scope: string? + +Returns: TeamApiKeyFirstView + +POST /teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] + +TeamApiKeyFirstView extends TeamApiKey with: + apiKey: string - the actual key value (only shown once) + +Does not error. + + +### listApiKeys() + +Returns: TeamApiKey[] + +GET /teams/{teamId}/api-keys [authenticated] + +TeamApiKey has: + id: string + description: string + expiresAt: Date | null + createdAt: Date + +Does not error. + + +## Customer Methods + +Team also implements Customer interface. See payments/customer.spec.md for: +- getItem(itemId) +- listItems() +- hasItem(itemId) +- getItemQuantity(itemId) +- listProducts() +- getBilling() +- getPaymentMethodSetupIntent() diff --git a/sdks/spec/src/types/users/base-user.spec.md b/sdks/spec/src/types/users/base-user.spec.md new file mode 100644 index 0000000000..b11330a109 --- /dev/null +++ b/sdks/spec/src/types/users/base-user.spec.md @@ -0,0 +1,73 @@ +# User (BaseUser) + +Base user type returned by client-side methods. Contains only publicly safe properties. + + +## Properties + +id: string + Unique user identifier. + +displayName: string | null + User's display name. + +primaryEmail: string | null + User's primary email address. + Note: NOT guaranteed unique across users. Always use `id` for identification. + +primaryEmailVerified: bool + Whether the primary email has been verified. + +profileImageUrl: string | null + URL to user's profile image. + +signedUpAt: Date + When the user signed up. + +clientMetadata: json + User-writable metadata, visible to client and server. + +clientReadOnlyMetadata: json + Server-writable metadata, visible to client but not writable by client. + +hasPassword: bool + Whether user has set a password for credential auth. + +otpAuthEnabled: bool + Whether TOTP-based MFA is enabled. + +passkeyAuthEnabled: bool + Whether passkey authentication is enabled. + +isMultiFactorRequired: bool + Whether MFA is required for this user. + +isAnonymous: bool + Whether this is an anonymous user. + +isRestricted: bool + Whether user is in restricted state (signed up but hasn't completed onboarding). + Example: email verification required but not yet verified. + +restrictedReason: { type: "anonymous" | "email_not_verified" } | null + The reason why user is restricted, or null if not restricted. + + +## Deprecated Properties + +emailAuthEnabled: bool + @deprecated - Use contact channel's usedForAuth instead. + +oauthProviders: { id: string }[] + @deprecated + + +## Methods + +toClientJson() + +Returns: CurrentUserCrud.Client.Read + +Serialize user to JSON format matching API response. + +Does not error. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md new file mode 100644 index 0000000000..db22f1b27b --- /dev/null +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -0,0 +1,435 @@ +# CurrentUser + +The authenticated user with methods to modify their own data. + +Extends: User (base-user.spec.md) + +Also includes: + - Auth methods (signOut, getAccessToken, etc.) + - Customer methods (payments/customer.spec.md) + + +## Additional Properties + +selectedTeam: Team | null + User's currently selected team. + Constructed from selected_team in API response. + + +## Session Properties + +currentSession.getTokens() + Returns: { accessToken: string | null, refreshToken: string | null } + Get current session tokens. + + +## update(options) + +options: { + displayName?: string | null, + clientMetadata?: json, + selectedTeamId?: string | null, + profileImageUrl?: string | null, + otpAuthEnabled?: bool, + passkeyAuthEnabled?: bool, + primaryEmail?: string | null, + totpMultiFactorSecret?: bytes | null, +} + +PATCH /users/me [authenticated] +Body: only include provided fields, convert to snake_case +Route: apps/backend/src/app/api/latest/users/me/route.ts + +Update local properties on success. + +Does not error. + + +## delete() + +DELETE /users/me [authenticated] +Route: apps/backend/src/app/api/latest/users/me/route.ts + +Clear stored tokens after success. + +Does not error. + + +## setDisplayName(displayName) + +displayName: string | null + +Shorthand for update({ displayName }). + +Does not error. + + +## setClientMetadata(metadata) + +metadata: json + +Shorthand for update({ clientMetadata: metadata }). + +Does not error. + + +## updatePassword(options) + +options.oldPassword: string +options.newPassword: string + +PATCH /users/me { old_password, new_password } [authenticated] + +Errors: + PasswordConfirmationMismatch + code: "password_confirmation_mismatch" + message: "The current password is incorrect." + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The new password does not meet the project's requirements." + + +## setPassword(options) + +options.password: string + +POST /users/me/password { password } [authenticated] + +For users without existing password (OAuth-only, anonymous). + +Errors: + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## Team Methods + + +### listTeams() + +Returns: Team[] + +GET /users/me/teams [authenticated] +Route: apps/backend/src/app/api/latest/users/me/teams/route.ts + +Construct Team for each item. + +Does not error. + + +### getTeam(teamId) + +teamId: string + +Returns: Team | null + +Call listTeams(), find by id, return null if not found. + +Does not error. + + +### createTeam(options) + +options.displayName: string +options.profileImageUrl: string? + +Returns: Team + +POST /teams { display_name, profile_image_url, creator_user_id: "me" } [authenticated] +Route: apps/backend/src/app/api/latest/teams/route.ts + +Then select the new team via update({ selectedTeamId: newTeam.id }). + +Does not error. + + +### setSelectedTeam(teamOrId) + +teamOrId: Team | string | null + +Shorthand for update({ selectedTeamId: extractId(teamOrId) }). + +Does not error. + + +### leaveTeam(team) + +team: Team + +DELETE /teams/{teamId}/users/me [authenticated] + +Does not error. + + +### getTeamProfile(team) + +team: Team + +Returns: EditableTeamMemberProfile + +GET /teams/{teamId}/users/me/profile [authenticated] + +EditableTeamMemberProfile has: + displayName: string | null + profileImageUrl: string | null + update(options): Promise + +Does not error. + + +## Contact Channel Methods + + +### listContactChannels() + +Returns: ContactChannel[] + +GET /contact-channels [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/route.ts + +Does not error. + + +### createContactChannel(options) + +options.type: "email" +options.value: string (the email address) +options.usedForAuth: bool +options.isPrimary: bool? + +Returns: ContactChannel + +POST /contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] + +Does not error. + + +## OAuth Provider Methods + + +### listOAuthProviders() + +Returns: OAuthProvider[] + +GET /users/me/oauth-providers [authenticated] +Route: apps/backend/src/app/api/latest/users/me/oauth-providers/route.ts + +OAuthProvider has: + id: string + type: string + userId: string + accountId: string? + email: string? + allowSignIn: bool + allowConnectedAccounts: bool + update(data): Promise> + delete(): Promise + +Does not error. + + +### getOAuthProvider(id) + +id: string + +Returns: OAuthProvider | null + +Find in listOAuthProviders() by id. + +Does not error. + + +## Connected Account Methods + + +### getConnectedAccount(providerId, options?) + +providerId: string (e.g., "google", "github") +options.scopes: string[]? - required OAuth scopes +options.or: "redirect" | "throw" | "return-null" + Default: "return-null" + +Returns: OAuthConnection | null + +POST /connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated] +Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts + +On success: return OAuthConnection with { id, getAccessToken() } + +On error "oauth_scope_not_granted" or "oauth_connection_not_connected": + - or="redirect": redirect to OAuth flow with additional scopes [BROWSER-ONLY] + - or="throw": throw the error + - or="return-null": return null + +Errors (only when or="throw"): + OAuthConnectionNotConnectedToUser + code: "oauth_connection_not_connected" + message: "You don't have this OAuth provider connected." + + OAuthConnectionDoesNotHaveRequiredScope + code: "oauth_scope_not_granted" + message: "The connected OAuth account doesn't have the required permissions." + + +## Permission Methods + + +### hasPermission(scope?, permissionId) + +scope: Team? - if omitted, checks project-level permission +permissionId: string + +Returns: bool + +GET /users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated] + +Does not error. + + +### getPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: TeamPermission | null + +Find permission by id in listPermissions(). + +Does not error. + + +### listPermissions(scope?, options?) + +scope: Team? +options.recursive: bool? - include inherited permissions + +Returns: TeamPermission[] + +GET /users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] + +Does not error. + + +## Session Methods + + +### getActiveSessions() + +Returns: ActiveSession[] + +GET /users/me/sessions [authenticated] + +ActiveSession has: + id: string + userId: string + createdAt: Date + isImpersonation: bool + lastUsedAt: Date | null + isCurrentSession: bool + geoInfo: GeoInfo? + +Does not error. + + +### revokeSession(sessionId) + +sessionId: string + +DELETE /users/me/sessions/{sessionId} [authenticated] + +Does not error. + + +## Passkey Methods + + +### registerPasskey(options?) [BROWSER-ONLY] + +options.hostname: string? + +Returns: Result + +Implementation: +1. POST /auth/passkey/register/initiate {} [authenticated] + Response: { options_json, code } +2. Replace options_json.rp.id with actual hostname +3. Call WebAuthn startRegistration(options_json) +4. POST /auth/passkey/register { credential, code } [authenticated] + +Errors (in Result): + PasskeyRegistrationFailed + code: "passkey_registration_failed" + message: "Failed to register passkey. Please try again." + + PasskeyWebAuthnError + code: "passkey_webauthn_error" + message: "WebAuthn error: {errorName}." + + +## API Key Methods + + +### listApiKeys() + +Returns: UserApiKey[] + +GET /users/me/api-keys [authenticated] + +Does not error. + + +### createApiKey(options) + +options.description: string +options.expiresAt: Date? +options.scope: string? - the scope/permissions +options.teamId: string? - for team-scoped keys + +Returns: UserApiKeyFirstView + +POST /users/me/api-keys { description, expires_at, scope, team_id } [authenticated] + +UserApiKeyFirstView extends UserApiKey with: + apiKey: string - the actual key value (only shown once) + +Does not error. + + +## Notification Methods + + +### listNotificationCategories() + +Returns: NotificationCategory[] + +GET /notification-categories [authenticated] + +Does not error. + + +## Auth Methods (from StackClientApp) + +signOut(options?) + Same as StackClientApp.signOut() + +getAccessToken() + Same as StackClientApp.getAccessToken() + +getRefreshToken() + Same as StackClientApp.getRefreshToken() + +getAuthHeaders() + Same as StackClientApp.getAuthHeaders() + + +## Deprecated Methods + +sendVerificationEmail() + @deprecated - Use contact channel's sendVerificationEmail instead. + + Errors: + EmailAlreadyVerified + code: "email_already_verified" + message: "This email is already verified." diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md new file mode 100644 index 0000000000..5cff0d3323 --- /dev/null +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -0,0 +1,268 @@ +# ServerUser + +Server-side user with full access to sensitive fields and management methods. + +Extends: User (base-user.spec.md) +Includes: UserExtra methods, Customer methods + + +## Additional Properties + +lastActiveAt: Date + When the user was last active. + +serverMetadata: json + Server-only metadata, not visible to client. + + +## Server-specific Update Methods + + +### update(options) + +options: { + displayName?: string | null, + clientMetadata?: json, + clientReadOnlyMetadata?: json, + serverMetadata?: json, + selectedTeamId?: string | null, + profileImageUrl?: string | null, + primaryEmail?: string | null, + primaryEmailVerified?: bool, + primaryEmailAuthEnabled?: bool, + password?: string, + otpAuthEnabled?: bool, + passkeyAuthEnabled?: bool, + totpMultiFactorSecret?: bytes | null, +} + +PATCH /users/{userId} [server-only] +Body: only include provided fields, convert to snake_case +Route: apps/backend/src/app/api/latest/users/[userId]/route.ts + +Does not error. + + +### setPrimaryEmail(email, options?) + +email: string | null +options.verified: bool? - set verification status + +Shorthand for update({ primaryEmail: email, primaryEmailVerified: options?.verified }). + +Does not error. + + +### setServerMetadata(metadata) + +metadata: json + +Shorthand for update({ serverMetadata: metadata }). + +Does not error. + + +### setClientReadOnlyMetadata(metadata) + +metadata: json + +Shorthand for update({ clientReadOnlyMetadata: metadata }). + +Does not error. + + +## Team Methods + + +### createTeam(options) + +options.displayName: string +options.profileImageUrl: string? + +Returns: ServerTeam + +POST /teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] + +Does not error. + + +### listTeams() + +Returns: ServerTeam[] + +GET /users/{userId}/teams [server-only] + +Does not error. + + +### getTeam(teamId) + +teamId: string + +Returns: ServerTeam | null + +Find in listTeams() by id. + +Does not error. + + +## Contact Channel Methods + + +### listContactChannels() + +Returns: ServerContactChannel[] + +GET /users/{userId}/contact-channels [server-only] + +ServerContactChannel extends ContactChannel with: + update(data: ServerContactChannelUpdateOptions): Promise + +ServerContactChannelUpdateOptions adds: + isVerified: bool? + +Does not error. + + +### createContactChannel(options) + +options.type: "email" +options.value: string +options.usedForAuth: bool +options.isPrimary: bool? +options.isVerified: bool? + +Returns: ServerContactChannel + +POST /contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only] + +Does not error. + + +## Permission Methods (with grant/revoke) + + +### grantPermission(scope?, permissionId) + +scope: Team? - if omitted, grants project-level permission +permissionId: string + +POST /users/{userId}/permissions { team_id, permission_id } [server-only] + +Does not error. + + +### revokePermission(scope?, permissionId) + +scope: Team? +permissionId: string + +DELETE /users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] + +Does not error. + + +### hasPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: bool + +GET /users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only] + +Does not error. + + +### getPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: TeamPermission | null + +Does not error. + + +### listPermissions(scope?, options?) + +scope: Team? +options.direct: bool? - only directly assigned, not inherited + +Returns: AdminTeamPermission[] + +GET /users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] + +Does not error. + + +## OAuth Provider Methods + + +### listOAuthProviders() + +Returns: ServerOAuthProvider[] + +GET /users/{userId}/oauth-providers [server-only] + +ServerOAuthProvider extends OAuthProvider with: + accountId: string (always present, not optional) + update(data): can also update accountId and email + +Does not error. + + +### getOAuthProvider(id) + +id: string + +Returns: ServerOAuthProvider | null + +Does not error. + + +## Session Methods + + +### createSession(options?) + +options.expiresInMillis: number? - session expiration +options.isImpersonation: bool? - mark as impersonation session + +Returns: { getTokens(): Promise<{ accessToken, refreshToken }> } + +POST /users/{userId}/sessions { expires_in_millis, is_impersonation } [server-only] + +Creates a new session for this user. Can be used to impersonate them. + +Does not error. + + +## All methods from UserExtra + +Also includes all methods from CurrentUser that are applicable: +- delete() +- setDisplayName(displayName) +- setClientMetadata(metadata) +- updatePassword(options) +- setPassword(options) +- listTeams() +- getTeam(teamId) +- createTeam(options) +- setSelectedTeam(teamOrId) +- leaveTeam(team) +- getTeamProfile(team) +- listContactChannels() +- createContactChannel(options) +- listOAuthProviders() +- getOAuthProvider(id) +- getConnectedAccount(providerId, options?) +- hasPermission(scope?, permissionId) +- getPermission(scope?, permissionId) +- listPermissions(scope?, options?) +- getActiveSessions() +- revokeSession(sessionId) +- registerPasskey(options?) [BROWSER-ONLY] +- listApiKeys() +- createApiKey(options) +- listNotificationCategories() From 493f6e71aacaee9d2314a6d0f0f34d2702887180 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 09:16:54 -0800 Subject: [PATCH 02/17] Spec updates --- pnpm-workspace.yaml | 1 + sdks/spec/README.md | 79 +- sdks/spec/package.json | 7 + sdks/spec/src/_errors.spec.md | 20 - sdks/spec/src/_utilities.spec.md | 191 ++++- sdks/spec/src/apps/admin-app.spec.md | 412 ---------- sdks/spec/src/apps/client-app.spec.md | 738 +++++++++++++++--- sdks/spec/src/apps/server-app.spec.md | 319 +++++--- .../src/types/auth/oauth-connection.spec.md | 10 +- .../contact-channels/contact-channel.spec.md | 8 +- sdks/spec/src/types/payments/customer.spec.md | 22 +- sdks/spec/src/types/payments/item.spec.md | 6 +- .../src/types/permissions/permission.spec.md | 151 ---- sdks/spec/src/types/projects/project.spec.md | 154 ---- sdks/spec/src/types/teams/server-team.spec.md | 12 +- sdks/spec/src/types/teams/team.spec.md | 14 +- .../spec/src/types/users/current-user.spec.md | 93 ++- sdks/spec/src/types/users/server-user.spec.md | 26 +- 18 files changed, 1119 insertions(+), 1144 deletions(-) create mode 100644 sdks/spec/package.json delete mode 100644 sdks/spec/src/_errors.spec.md delete mode 100644 sdks/spec/src/apps/admin-app.spec.md diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c22ad7549e..fe1fe65c2f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ packages: - apps/* - examples/* - docs + - sdks/* minimumReleaseAge: 2880 diff --git a/sdks/spec/README.md b/sdks/spec/README.md index 150c80a280..3b029df45b 100644 --- a/sdks/spec/README.md +++ b/sdks/spec/README.md @@ -1,47 +1,6 @@ # Stack Auth SDK Specification -This folder contains the specification for generating Stack Auth SDKs in multiple programming languages. - -## Purpose - -The spec files describe the SDK interface and behavior in a language-agnostic way. When given to an AI code generator (like Claude or Cursor), they should produce functionally equivalent SDKs in any target language. - -## Repository Structure - -``` -sdks/ -├── spec/ # This folder - SDK specification -│ ├── README.md -│ ├── _utilities.spec.md # Common patterns (auth, HTTP, tokens) -│ ├── _errors.spec.md # Common error types -│ ├── apps/ -│ │ ├── client-app.spec.md # StackClientApp -│ │ ├── server-app.spec.md # StackServerApp -│ │ └── admin-app.spec.md # StackAdminApp -│ └── types/ -│ ├── users/ -│ │ ├── base-user.spec.md # User base properties -│ │ ├── current-user.spec.md # CurrentUser (authenticated) -│ │ └── server-user.spec.md # ServerUser -│ ├── teams/ -│ │ ├── team.spec.md # Team -│ │ └── server-team.spec.md # ServerTeam -│ ├── auth/ -│ │ └── oauth-connection.spec.md -│ ├── contact-channels/ -│ │ └── contact-channel.spec.md -│ ├── projects/ -│ │ └── project.spec.md -│ ├── permissions/ -│ │ └── permission.spec.md -│ └── payments/ -│ ├── customer.spec.md -│ └── item.spec.md -└── implementations/ # Generated SDKs (by language) - ├── python/ - ├── go/ - └── ... -``` +This folder contains the specification for Stack Auth's SDKs. ## Notation @@ -51,37 +10,23 @@ The spec files use the following notation: |----------|---------| | `[authenticated]` | Include access token, handle 401 refresh | | `[server-only]` | Requires secretServerKey | -| `[admin-only]` | Requires superSecretAdminKey | -| `[BROWSER-ONLY]` | Requires browser environment | +| `[BROWSER-LIKE]` | Requires browser or browser-like environment (browser, WebView, in-app browser). On mobile, open an in-app browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android). On desktop, open the system browser with a registered URL scheme. | +| `[BROWSER-ONLY]` | Strictly requires browser environment (DOM, window object) | +| `[CLI-ONLY]` | Only in languages/platforms with an interactive terminal | +| `[JS-ONLY]` | Only available in the JavaScript SDK | | `{ field, field }` | Request body (JSON) | | `"Does not error"` | Function handles errors internally | | `"Errors: ..."` | Lists possible errors with code/message | +See _utilities.spec.md for more details. + ## Language Adaptation -The generator should adapt: +The languages should adapt: -- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go) +- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go), etc. - **Async patterns**: Promises (JS), async/await (Python), goroutines (Go) - **Error handling**: Exceptions vs Result types (language preference) -- **Framework hooks**: For React, add `use*` equivalents to `get*`/`list*` methods - -## Usage - -To generate an SDK: - -1. Provide these spec files to an AI code generator -2. Specify the target language and any framework requirements -3. The generator produces implementation code in `sdks/implementations//` - -Example prompt for Python: -``` -Generate a Python SDK from the Stack Auth specification in sdks/spec/. -Use snake_case naming, async/await with httpx, and raise exceptions for errors. -Output to sdks/implementations/python/ -``` - -Example prompt for React: -``` -All get* and list* functions should have a use* hook equivalent. -``` +- **Parameter conventions**: Objects vs. kwargs, etc. +- **Framework hooks**: Eg. for React, add `use*` equivalents to `get*`/`list*` methods +- **Everything else, wherever it makes sense**: Every language is unique and the patterns will differ. If you have to decide between what's idiomatic in a language vs. what was done in the Stack Auth SDK for other languages, use the idiomatic pattern. diff --git a/sdks/spec/package.json b/sdks/spec/package.json new file mode 100644 index 0000000000..c9d702b383 --- /dev/null +++ b/sdks/spec/package.json @@ -0,0 +1,7 @@ +{ + "name": "@stackframe/sdk-spec", + "version": "0.0.0", + "private": true, + "description": "Stack Auth SDK specification files", + "scripts": {} +} diff --git a/sdks/spec/src/_errors.spec.md b/sdks/spec/src/_errors.spec.md deleted file mode 100644 index a8fd12b9ad..0000000000 --- a/sdks/spec/src/_errors.spec.md +++ /dev/null @@ -1,20 +0,0 @@ -# Common Errors - -Errors used by many functions. Function-specific errors are defined inline. - - -## VerificationCodeError - -code: "verification_code_error" -message: "The verification code is invalid or has expired." - -Used by: verifyEmail, resetPassword, signInWithMagicLink, acceptTeamInvitation, etc. - - -## ApiError - -code: -message: - -Generic wrapper for unexpected API errors. -Properties: code, message, details (optional object) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index fb5783e481..1c5d74b32e 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -3,74 +3,189 @@ Common patterns referenced by bracketed notation in other spec files. -## [authenticated] - Authenticated Request +## Sending Requests -Include header: - x-stack-access-token: +All API requests follow this pattern. This section describes the complete request lifecycle. -On 401 with code="access_token_expired": do [token-refresh], retry once. -On 401 after retry: treat as unauthenticated. +### Base URL +Construct API URL: `{baseUrl}/api/v1{path}` + - baseUrl defaults to "https://api.stack-auth.com" + - Remove trailing slash from final URL + - Example: `https://api.stack-auth.com/api/v1/users/me` -## [token-refresh] - Token Refresh -POST /auth/sessions/current/refresh -Headers: x-stack-refresh-token: -Route: apps/backend/src/app/api/latest/auth/sessions/current/refresh/route.ts +### Required Headers (every request) -On 200: { access_token, refresh_token } - store both -On error: clear tokens, user is signed out +x-stack-project-id: +x-stack-publishable-client-key: +x-stack-client-version: "@" (e.g., "python@1.0.0", "go@0.1.0") +x-stack-access-type: "client" | "server" | "admin" + - "client" for StackClientApp + - "server" for StackServerApp (also include server key header) +x-stack-override-error-status: "true" + - Tells server to return errors as 200 with x-stack-actual-status header + - This works around some platforms that intercept non-200 responses +x-stack-random-nonce: + - Cache buster to prevent framework caching (e.g., Next.js) + - Generate a new random string for each request +content-type: application/json (for requests with body) -## [server-only] - Server Key Required +### Authentication Headers [authenticated] + +Include when session tokens are available: + +x-stack-access-token: +x-stack-refresh-token: (if available) + +On 401 response with code="invalid_access_token": +1. Mark access token as expired +2. Fetch new access token using refresh token (see Token Refresh below) +3. Retry the request with the new token +4. If still 401 after retry: treat as unauthenticated + + +### Token Refresh + +Use OAuth2 refresh_token grant to get new access token: + +POST /api/v1/auth/oauth/token +Content-Type: application/x-www-form-urlencoded + +Body (form-encoded): + grant_type: refresh_token + refresh_token: + client_id: + client_secret: + +Response on success: + { access_token: string, refresh_token?: string, ... } + +On error (e.g., refresh_token_error): clear tokens, user is signed out. + +Use an OAuth library (e.g., oauth4webapi) for proper OAuth2 handling. + + +### [server-only] - Server Key Required Include header: x-stack-secret-server-key: -Only available in StackServerApp and StackAdminApp. +Only available in StackServerApp. + + +### Retry Logic + +For network errors (TypeError from fetch) on idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE): +1. Retry up to 5 times +2. Use exponential backoff: delay = 1000ms * 2^attempt +3. If all retries fail: throw network error with diagnostics + +For rate limiting (429 response): +1. Check Retry-After header for delay (in seconds) +2. Wait that duration, then retry +3. If no Retry-After header: retry immediately with backoff + +### Response Processing -## [admin-only] - Admin Key Required +1. Check x-stack-actual-status header for real status code + (Server may return 200 with actual status in this header) -Include header: x-stack-super-secret-admin-key: -Only available in StackAdminApp. +2. Check x-stack-known-error header for error code + If present: body is { code, message, details? } + Parse into appropriate error type +3. On success (2xx): parse JSON body and return -## Base Request Headers -Always include on every request: - x-stack-project-id: - x-stack-publishable-client-key: - x-stack-client-version: "@" (e.g. "python@1.0.0") - content-type: application/json +### Credentials + +Set credentials: "omit" on fetch to avoid sending cookies cross-origin. +(Skip this on platforms that don't support it, e.g., Cloudflare Workers) + + +### Cache Control + +Set cache: "no-store" to prevent caching. +(Skip this on platforms that don't support it) ## Error Response Format -4xx/5xx responses have body: { code: string, message: string, details?: object } +If the response has x-stack-known-error header, the body has shape: + { code: string, message: string, details?: object } + +The code matches the x-stack-known-error header value. +See packages/stack-shared/src/known-errors.ts for all error types. + + +## StackAuthApiError + +The base error type for all Stack Auth API errors. + +Properties: + code: string - error code from API (e.g., "user_not_found") + message: string - human-readable error message + details: object? - optional additional details -Map `code` to error type. Unknown codes create generic ApiError. +All function-specific errors (like PasswordResetCodeInvalid, EmailPasswordMismatch, etc.) +should extend or be instances of StackAuthApiError. + +For unrecognized error codes, create a StackAuthApiError with the code and message from the response. ## Token Storage -Store access_token and refresh_token. Strategy from constructor: +Store access_token and refresh_token. The tokenStore constructor option determines storage strategy. + +Many functions also accept a tokenStore parameter to override storage for that call. -"cookie": - Browser cookies: "stack-refresh-{projectId}", "stack-access" - Options: Secure=true in production, SameSite=Lax +### Token Store Types +"cookie": [JS-ONLY] + Store tokens in browser cookies. Requires browser environment. + Due to cookie complexity (Secure flags, SameSite, Partitioned/CHIPS, HTTPS detection), + this is only implemented in the JS SDK. Other SDKs should use "memory" or explicit tokens. + "memory": - Runtime variable, lost on restart + Store tokens in runtime memory. Lost on page refresh or process restart. + Useful for short-lived sessions, CLI tools, or server-side scripts. + +{ accessToken, refreshToken } object: + Use explicit token values directly. + For custom token management scenarios. + +null: + No token storage. SDK methods requiring authentication will fail. Most useful for backends, as you can still specify the token store per-request. + + +### x-stack-auth Header Format + +For cross-origin requests or server-side handling, use this header: + x-stack-auth: { "accessToken": "", "refreshToken": "" } + +JSON-encoded object with both tokens. +Use getAuthHeaders() to generate this header value. + +## MFA Handling Pattern -RequestLike object: - Read x-stack-auth header (JSON: { accessToken, refreshToken }) - For server-side request handling +Several sign-in methods may return MultiFactorAuthenticationRequired error when MFA is enabled. +Error format: + code: "multi_factor_authentication_required" + message: "Multi-factor authentication is required." + details: { attempt_code: string } -## Naming Conventions +When this error is received: +1. Store the attempt_code (e.g., in sessionStorage) +2. Redirect user to the MFA page (urls.mfa) +3. User enters their 6-digit TOTP code +4. Call signInWithMfa(otp, attemptCode) to complete sign-in -SDK uses language-appropriate naming: - - JS/TS: camelCase (displayName, getUser) - - Python: snake_case (display_name, get_user) - - Go: PascalCase exports (DisplayName, GetUser) +Methods that can return this error: +- signInWithCredential +- signInWithMagicLink +- signInWithPasskey +- callOAuthCallback -API always uses snake_case in JSON. +The attempt_code is short-lived and single-use. diff --git a/sdks/spec/src/apps/admin-app.spec.md b/sdks/spec/src/apps/admin-app.spec.md deleted file mode 100644 index 2b9ec03cb1..0000000000 --- a/sdks/spec/src/apps/admin-app.spec.md +++ /dev/null @@ -1,412 +0,0 @@ -# StackAdminApp - -Extends StackServerApp with administrative capabilities. Requires superSecretAdminKey. - - -## Constructor - -StackAdminApp(options) - -Extends StackServerApp constructor options with: - -Required: - superSecretAdminKey: string - from Stack Auth dashboard - -Optional: - projectOwnerSession: InternalSession - for internal use only - - -## getProject() - -Returns: AdminProject - -GET /projects/current [admin-only] -Route: apps/backend/src/app/api/latest/projects/current/route.ts - -AdminProject extends Project with full configuration access and update methods. - -Does not error. - - -## Permission Definition Methods - - -### listTeamPermissionDefinitions() - -Returns: AdminTeamPermissionDefinition[] - -GET /team-permission-definitions [admin-only] -Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts - -Does not error. - - -### createTeamPermissionDefinition(options) - -options.id: string - permission identifier (e.g., "read", "admin") -options.description: string? - -Returns: AdminTeamPermission - -POST /team-permission-definitions { id, description } [admin-only] - -Does not error. - - -### updateTeamPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? - -PATCH /team-permission-definitions/{permissionId} { description } [admin-only] - -Does not error. - - -### deleteTeamPermissionDefinition(permissionId) - -permissionId: string - -DELETE /team-permission-definitions/{permissionId} [admin-only] - -Does not error. - - -### listProjectPermissionDefinitions() - -Returns: AdminProjectPermissionDefinition[] - -GET /project-permission-definitions [admin-only] - -Does not error. - - -### createProjectPermissionDefinition(options) - -options.id: string -options.description: string? - -Returns: AdminProjectPermission - -POST /project-permission-definitions { id, description } [admin-only] - -Does not error. - - -### updateProjectPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? - -PATCH /project-permission-definitions/{permissionId} { description } [admin-only] - -Does not error. - - -### deleteProjectPermissionDefinition(permissionId) - -permissionId: string - -DELETE /project-permission-definitions/{permissionId} [admin-only] - -Does not error. - - -## API Key Methods - - -### listInternalApiKeys() - -Returns: InternalApiKey[] - -GET /internal/api-keys [admin-only] - -InternalApiKey has: - id: string - description: string - expiresAt: Date | null - createdAt: Date - isPublishableClientKey: bool - isSecretServerKey: bool - isSuperSecretAdminKey: bool - hasPublishableClientKey: bool - hasSecretServerKey: bool - hasSuperSecretAdminKey: bool - userId: string | null - teamId: string | null - -Does not error. - - -### createInternalApiKey(options) - -options.description: string -options.expiresAt: Date? -options.isPublishableClientKey: bool? -options.isSecretServerKey: bool? -options.isSuperSecretAdminKey: bool? -options.userId: string? -options.teamId: string? - -Returns: InternalApiKeyFirstView - -POST /internal/api-keys { ... } [admin-only] - -InternalApiKeyFirstView extends InternalApiKey with: - publishableClientKey: string | null - secretServerKey: string | null - superSecretAdminKey: string | null - -Does not error. - - -## Email Methods - - -### sendTestEmail(options) - -options.recipientEmail: string -options.emailConfig: EmailConfig - -Returns: Result - -POST /internal/email/test { recipient_email, email_config } [admin-only] - -Sends a test email to verify email configuration. - -Does not error (returns Result). - - -### sendSignInInvitationEmail(email, callbackUrl) - -email: string -callbackUrl: string - -POST /auth/magic-link/send { email, callback_url, type: "sign_in_invitation" } [admin-only] - -Does not error. - - -### listSentEmails() - -Returns: AdminSentEmail[] - -GET /internal/sent-emails [admin-only] - -Does not error. - - -### Email Theme Methods - -listEmailThemes(): AdminEmailTheme[] -createEmailTheme(displayName): { id: string } -updateEmailTheme(id, tsxSource): void - -### Email Template Methods - -listEmailTemplates(): AdminEmailTemplate[] -createEmailTemplate(displayName): { id: string } -updateEmailTemplate(id, tsxSource, themeId): { renderedHtml: string } - -### Email Draft Methods - -listEmailDrafts(): AdminEmailDraft[] -createEmailDraft(options): { id: string } -updateEmailDraft(id, data): void - -### Email Preview - -getEmailPreview(options): string (rendered HTML) - - -## Email Outbox Methods - - -### listOutboxEmails(options?) - -options.status: string? - filter by status -options.simpleStatus: string? - filter by simple status -options.limit: number? -options.cursor: string? - -Returns: { items: AdminEmailOutbox[], nextCursor: string | null } - -GET /internal/email-outbox [admin-only] - -Does not error. - - -### getOutboxEmail(id) - -id: string - -Returns: AdminEmailOutbox - -GET /internal/email-outbox/{id} [admin-only] - -Does not error. - - -### updateOutboxEmail(id, options) - -id: string -options.isPaused: bool? -options.scheduledAtMillis: number? -options.cancel: bool? - -Returns: AdminEmailOutbox - -PATCH /internal/email-outbox/{id} { is_paused, scheduled_at_millis, cancel } [admin-only] - -Does not error. - - -### pauseOutboxEmail(id) - -id: string - -Shorthand for updateOutboxEmail(id, { isPaused: true }) - - -### unpauseOutboxEmail(id) - -id: string - -Shorthand for updateOutboxEmail(id, { isPaused: false }) - - -### cancelOutboxEmail(id) - -id: string - -Shorthand for updateOutboxEmail(id, { cancel: true }) - - -## Webhook Methods - - -### sendTestWebhook(options) - -options.endpointId: string - -Returns: Result - -POST /internal/webhooks/test { endpoint_id } [admin-only] - -Does not error (returns Result). - - -## Payment Methods - - -### setupPayments() - -Returns: { url: string } - -POST /internal/payments/setup [admin-only] - -Returns Stripe onboarding URL. - -Does not error. - - -### getStripeAccountInfo() - -Returns: StripeAccountInfo | null - -GET /internal/payments/stripe-account [admin-only] - -StripeAccountInfo has: - account_id: string - charges_enabled: bool - details_submitted: bool - payouts_enabled: bool - -Does not error. - - -### createStripeWidgetAccountSession() - -Returns: { client_secret: string } - -POST /internal/payments/stripe-widget-session [admin-only] - -For embedded Stripe dashboard components. - -Does not error. - - -### createItemQuantityChange(options) - -Customer identification (one of): - options.userId: string - options.teamId: string - options.customCustomerId: string - -options.itemId: string -options.quantity: number - positive to add, negative to subtract -options.expiresAt: string? - ISO date for expiration -options.description: string? - -POST /internal/items/quantity-changes { ... } [admin-only] - -Does not error. - - -### refundTransaction(options) - -options.type: "subscription" | "one-time-purchase" -options.id: string - -POST /internal/transactions/{type}/{id}/refund [admin-only] - -Does not error. - - -### listTransactions(options?) - -options.cursor: string? -options.limit: number? -options.type: TransactionType? -options.customerType: "user" | "team" | "custom"? - -Returns: { transactions: Transaction[], nextCursor: string | null } - -GET /internal/transactions [admin-only] - -Does not error. - - -## Chat Methods (Email Editor AI) - - -### sendChatMessage(threadId, contextType, messages, abortSignal?) - -threadId: string -contextType: "email-theme" | "email-template" | "email-draft" -messages: Array<{ role: string, content: any }> -abortSignal: AbortSignal? - -Returns: { content: ChatContent } - -POST /internal/chat/send { thread_id, context_type, messages } [admin-only] - -For AI-assisted email editing. - -Does not error. - - -### saveChatMessage(threadId, message) - -POST /internal/chat/messages { thread_id, message } [admin-only] - -Does not error. - - -### listChatMessages(threadId) - -Returns: { messages: Array } - -GET /internal/chat/messages?thread_id={threadId} [admin-only] - -Does not error. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index b005e8377e..0a187c91ba 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -1,6 +1,6 @@ # StackClientApp -The main client-side SDK class. Safe for browser use. +The main client-side SDK class. ## Constructor @@ -16,10 +16,10 @@ Optional: Default: "https://api.stack-auth.com" Can specify different URLs for browser vs server environments. - tokenStore: "cookie" | "memory" | RequestLike - Default: "cookie" + tokenStore: "cookie" | "memory" | { accessToken, refreshToken } | null + Default: "cookie" (JS) or "memory" (other SDKs) Where to store authentication tokens. - "cookie" requires browser environment. + "cookie" is JS-only due to complexity. See _utilities.spec.md for details. urls: object Override handler URLs. Defaults under "/handler": @@ -28,37 +28,102 @@ Optional: afterSignIn: "/" afterSignUp: "/" ... see apps/backend for full list + + oauthScopesOnSignIn: object + Additional OAuth scopes to request during sign-in for each provider. + Example: { google: ["https://www.googleapis.com/auth/calendar"] } + + extraRequestHeaders: object + Additional headers to include in every API request. + + redirectMethod: "nextjs" | "browser" | "none" + How to perform redirects. + "nextjs": Use Next.js redirect() function [JS-ONLY] + "browser": Use window.location for client-side redirects + "none": Don't redirect, return control to caller + + noAutomaticPrefetch: bool + Default: false + If true, skip prefetching project info on construction. On construct: prefetch project info (GET /projects/current) unless noAutomaticPrefetch=true. -## signInWithOAuth(provider, options?) [BROWSER-ONLY] +## signInWithOAuth(provider, options?) [BROWSER-LIKE] -provider: string - e.g. "google", "github", "microsoft" -options.returnTo: string? - URL to redirect after auth completes +Starts an OAuth authentication flow with the specified provider. +Use an OAuth library (e.g., oauth4webapi) to handle PKCE and state management. -Implementation: -1. Generate 32-char random state string -2. Store state in sessionStorage with key "stack-oauth-{state}" -3. Redirect browser to: /auth/oauth/authorize/{provider} - Query params: state, redirect_uri, after_callback_redirect_url - Route: apps/backend/src/app/api/latest/auth/oauth/authorize/[provider]/route.ts +Arguments: + provider: string - OAuth provider ID (e.g., "google", "github", "microsoft") + options.returnTo: string? - URL to return to after OAuth completes (default: urls.oauthCallback) -Does not return (redirects browser). -Does not error. +Returns: never (opens browser/webview and redirects) + +Note: Additional provider scopes are configured via oauthScopesOnSignIn constructor option. + +Implementation: +1. Generate PKCE code verifier (43+ character random string) +2. Compute code challenge: base64url(sha256(code_verifier)) +3. Generate random state string for CSRF protection +4. Store code verifier for later retrieval, keyed by state + - Browser: cookie "stack-oauth-outer-{state}" (maxAge: 1 hour) + - Mobile/other: secure storage appropriate to the platform + +5. Build authorization URL: + GET /api/v1/auth/oauth/authorize/{provider} + Query params: + client_id: + client_secret: + redirect_uri: (with code/state params removed if present) + scope: "legacy" + state: + grant_type: "authorization_code" + code_challenge: + code_challenge_method: "S256" + response_type: "code" + type: "authenticate" + error_redirect_url: + token: (optional) + provider_scope: (if provided) + + Response: HTTP redirect (302) to OAuth provider's authorization page + +6. Open the authorization URL: + - Browser: window.location.assign(authorization_url) + - Mobile: Open in-app browser/WebView (e.g., ASWebAuthenticationSession on iOS, + Custom Tabs on Android) with the callback URL registered as a deep link + - Desktop: Open system browser with registered URL scheme for callback + +7. Never returns (control transfers to browser/webview) + +The flow continues when the user is redirected back to urls.oauthCallback. +Call callOAuthCallback() on the callback page/handler to complete the flow. + +Does not error (redirects before any error can occur). ## signInWithCredential(options) -options.email: string -options.password: string -options.noRedirect: bool? - if true, don't redirect after success +Arguments: + options.email: string + options.password: string + options.noRedirect: bool? - if true, don't redirect after success + +Returns: void -POST /auth/password/sign-in { email, password } -Route: apps/backend/src/app/api/latest/auth/password/sign-in/route.ts +Request: + POST /api/v1/auth/password/sign-in + Body: { email: string, password: string } -On 200: store tokens { access_token, refresh_token } - redirect to afterSignIn URL (unless noRedirect=true) +Response on success: + { access_token: string, refresh_token: string } + +Implementation: +1. Send request +2. On MFA required: redirect to MFA page (stores attempt_code in sessionStorage) +3. Store tokens { access_token, refresh_token } +4. Redirect to afterSignIn URL (unless noRedirect=true) Errors: EmailPasswordMismatch @@ -67,20 +132,39 @@ Errors: InvalidTotpCode code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + message: "The MFA code is incorrect." ## signUpWithCredential(options) -options.email: string -options.password: string -options.verificationCallbackUrl: string? - URL for email verification link -options.noRedirect: bool? +Arguments: + options.email: string + options.password: string + options.verificationCallbackUrl: string? - URL for email verification link + options.noVerificationCallback: bool? - if true, skip email verification + options.noRedirect: bool? + +Returns: void -POST /auth/password/sign-up { email, password, verification_callback_url } -Route: apps/backend/src/app/api/latest/auth/password/sign-up/route.ts +Request: + POST /api/v1/auth/password/sign-up + Body: { + email: string, + password: string, + verification_callback_url: string? + } -On 200: store tokens, redirect to afterSignUp (unless noRedirect=true) +Response on success: + { access_token: string, refresh_token: string } + +Implementation: +1. If noVerificationCallback and verificationCallbackUrl both set: throw error +2. Build verification URL (unless noVerificationCallback=true) +3. Send request +4. If redirect URL not whitelisted error AND we didn't opt out of verification: + - Log warning, retry without verification URL +5. Store tokens { access_token, refresh_token } +6. Redirect to afterSignUp URL (unless noRedirect=true) Errors: UserWithEmailAlreadyExists @@ -94,40 +178,69 @@ Errors: ## signOut(options?) -options.redirectUrl: string? - where to redirect after sign out +Arguments: + options.redirectUrl: string? - where to redirect after sign out -POST /auth/sessions/current/sign-out [authenticated] - Ignore errors (session may already be invalid) -Clear stored tokens. -Redirect to redirectUrl or afterSignOut URL. +Returns: void -Does not error. +Request: + DELETE /api/v1/auth/sessions/current [authenticated] + Body: {} + +Implementation: +1. Send request (ignore errors - session may already be invalid) +2. Clear stored tokens (mark session invalid) +3. Redirect to redirectUrl or afterSignOut URL + +Does not error (errors are ignored). ## getUser(options?) -options.or: "redirect" | "throw" | "return-null" | "anonymous" - Default: "return-null" -options.includeRestricted: bool? - Default: false - Whether to return users who haven't completed onboarding (email verification, etc.) +Arguments: + options.or: "redirect" | "throw" | "return-null" | "anonymous" + Default: "return-null" + options.includeRestricted: bool? + Default: false + Whether to return users who haven't completed onboarding Returns: CurrentUser | null +IMPORTANT: { or: 'anonymous' } and { includeRestricted: false } are mutually exclusive. +Anonymous users are always restricted, so this combination doesn't make sense. +Throw an error if both are specified. + +Request (to fetch user): + GET /api/v1/users/me [authenticated] + +Response on success: + CurrentUserCrud object (see types/users/current-user.spec.md for full schema) + +Request (to create anonymous user): + POST /api/v1/auth/anonymous/sign-up + Body: {} + +Response: + { access_token: string, refresh_token: string } + Implementation: 1. Get tokens from storage -2. If no tokens: +2. Determine flags: + - includeAnonymous = (or == "anonymous") + - includeRestricted = (includeRestricted == true) OR includeAnonymous +3. If no tokens: - "redirect": redirect to signIn URL, never returns - "throw": throw UserNotSignedIn error - - "anonymous": POST /auth/users (creates anonymous user), store tokens, continue + - "anonymous": create anonymous user (POST above), store tokens, continue - "return-null": return null -3. GET /users/me [authenticated] - Route: apps/backend/src/app/api/latest/users/me/route.ts -4. On 401: [token-refresh], retry once. If still 401: handle as step 2 -5. On 200: construct CurrentUser object (types/users/current-user.spec.md) -6. If user.isRestricted and not includeRestricted: - - "redirect": redirect to onboarding URL - - otherwise: handle as step 2 +4. GET /api/v1/users/me [authenticated] +5. On 401: token refresh & retry. If still 401: handle as step 3 +6. On 200: construct CurrentUser object +7. Filter based on user state: + - If user.isAnonymous and not includeAnonymous: handle as step 3 + - If user.isRestricted and not includeRestricted: + - "redirect": redirect to onboarding URL (not sign-in!) + - otherwise: handle as step 3 Errors (only when or="throw"): UserNotSignedIn @@ -139,20 +252,91 @@ Errors (only when or="throw"): Returns: Project -GET /projects/current -Route: apps/backend/src/app/api/latest/projects/current/route.ts +Request: + GET /api/v1/projects/current + +Response: + { + id: string, + display_name: string, + config: { + sign_up_enabled: bool, + credential_enabled: bool, + magic_link_enabled: bool, + passkey_enabled: bool, + oauth_providers: [{ id: string, type: string }], + client_team_creation_enabled: bool, + client_user_deletion_enabled: bool, + domains: [{ domain: string, handler_path: string }] + } + } Construct Project object (types/projects/project.spec.md). Does not error. +## getPartialUser(options) + +Get minimal user info without a full API call. +Useful for quickly checking auth state. + +Arguments: + options.from: "token" | "convex" + - "token": Extract user info from the stored access token (JWT claims) + - "convex": Extract user info from Convex auth context [JS-ONLY] + + For "convex" [JS-ONLY]: + options.ctx: ConvexQueryContext - the Convex query context + +Returns: TokenPartialUser | null + +TokenPartialUser: + id: string + displayName: string | null + primaryEmail: string | null + primaryEmailVerified: bool + isAnonymous: bool + isRestricted: bool + restrictedReason: { type: "anonymous" | "email_not_verified" } | null + +Implementation for "token": +1. Get access token from storage +2. If no token: return null +3. Decode JWT payload (base64url decode middle segment) +4. Extract fields: sub (id), name, email, email_verified, is_anonymous, is_restricted, restricted_reason + +Implementation for "convex" [JS-ONLY]: +1. Call ctx.auth.getUserIdentity() +2. If null: return null +3. Map: subject→id, name→displayName, email, email_verified, is_anonymous, is_restricted, restricted_reason + +Does not error. + + +## cancelSubscription(options) + +Cancel an active subscription. + +Arguments: + options.productId: string - the subscription product to cancel + options.teamId: string? - if canceling a team subscription + +Returns: void + +Request: + POST /api/v1/subscriptions/cancel [authenticated] + Body: { product_id: string, team_id?: string } + +Does not error. + + ## getAccessToken() Returns: string | null Get access token from storage. -If expired: [token-refresh]. +If expired or expiring soon: perform token refresh (see _utilities.spec.md). Return token string, or null if not authenticated. Does not error. @@ -172,19 +356,25 @@ Does not error. Returns: { "x-stack-auth": string } -JSON-encode { accessToken, refreshToken } into header value. -For cross-origin authenticated requests. +Get current tokens and JSON-encode as header value: + { "accessToken": "", "refreshToken": "" } + +For cross-origin authenticated requests where cookies can't be sent. Does not error. ## sendForgotPasswordEmail(email, options?) -email: string -options.callbackUrl: string? - URL for password reset link +Arguments: + email: string + options.callbackUrl: string? - URL for password reset link (default: urls.passwordReset) -POST /auth/password/forgot { email, callback_url } -Route: apps/backend/src/app/api/latest/auth/password/forgot/route.ts +Returns: void + +Request: + POST /api/v1/auth/password/send-reset-code + Body: { email: string, callback_url: string } Errors: UserNotFound @@ -192,17 +382,43 @@ Errors: message: "No user with this email address was found." +## verifyPasswordResetCode(code) + +Verifies a password reset code is valid before showing the reset form. +Call this before showing the password input to avoid user frustration. + +Arguments: + code: string - from password reset email URL + +Returns: void + +Request: + POST /api/v1/auth/password/reset/check-code + Body: { code: string } + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + ## resetPassword(options) -options.code: string - from password reset email -options.password: string - new password +Arguments: + options.code: string - from password reset email + options.password: string - new password -POST /auth/password/reset { code, password } -Route: apps/backend/src/app/api/latest/auth/password/reset/route.ts +Returns: void + +Request: + POST /api/v1/auth/password/reset + Body: { code: string, password: string } Errors: - VerificationCodeError (see _errors.spec.md) - + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + PasswordRequirementsNotMet code: "password_requirements_not_met" message: "The password does not meet the project's requirements." @@ -210,13 +426,18 @@ Errors: ## sendMagicLinkEmail(email, options?) -email: string -options.callbackUrl: string? +Arguments: + email: string + options.callbackUrl: string? - (default: urls.magicLinkCallback) Returns: { nonce: string } -POST /auth/magic-link/send { email, callback_url } -Route: apps/backend/src/app/api/latest/auth/magic-link/send/route.ts +Request: + POST /api/v1/auth/otp/send-sign-in-code + Body: { email: string, callback_url: string } + +Response: + { nonce: string } Errors: RedirectUrlNotWhitelisted @@ -226,33 +447,99 @@ Errors: ## signInWithMagicLink(code, options?) -code: string - from magic link URL -options.noRedirect: bool? +Arguments: + code: string - from magic link URL + options.noRedirect: bool? -POST /auth/magic-link/sign-in { code } -Route: apps/backend/src/app/api/latest/auth/magic-link/sign-in/route.ts +Returns: void -On 200: store tokens - redirect to afterSignIn or afterSignUp based on newUser flag (unless noRedirect) +Request: + POST /api/v1/auth/otp/sign-in + Body: { code: string } + +Response on success: + { access_token: string, refresh_token: string, is_new_user: bool } + +Implementation: +1. Send request +2. On MFA required: redirect to MFA page (stores attempt_code in sessionStorage) +3. Store tokens { access_token, refresh_token } +4. Redirect to afterSignIn or afterSignUp based on is_new_user (unless noRedirect) Errors: - VerificationCodeError (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." InvalidTotpCode code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + message: "The MFA code is incorrect." + + +## signInWithMfa(totp, code, options?) + +Completes sign-in when MFA is required. +Called after receiving MultiFactorAuthenticationRequired error from another sign-in method. + +Arguments: + totp: string - 6-digit TOTP code from authenticator app + code: string - the attempt code from MFA error or sessionStorage + options.noRedirect: bool? + +Returns: void + +Request: + POST /api/v1/auth/mfa/sign-in + Body: { type: "totp", totp: string, code: string } + +Response on success: + { access_token: string, refresh_token: string, is_new_user: bool } + +Implementation: +1. Send request +2. Store tokens { access_token, refresh_token } +3. Redirect to afterSignIn or afterSignUp based on is_new_user (unless noRedirect) + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect." + + +## signInWithPasskey() [BROWSER-LIKE] +Returns: void -## signInWithPasskey() [BROWSER-ONLY] +Requires WebAuthn support: +- Browser: native WebAuthn API +- iOS: ASAuthorizationPlatformPublicKeyCredentialProvider +- Android: FIDO2 API via Google Play Services Implementation: -1. POST /auth/passkey/authenticate/initiate {} - Response: { options_json, code } -2. Replace options_json.rpId with window.location.hostname -3. Call WebAuthn API startAuthentication(options_json) - Requires WebAuthn library (e.g., @simplewebauthn/browser) -4. POST /auth/passkey/authenticate { authentication_response, code } -5. On 200: store tokens, redirect to afterSignIn +1. Initiate authentication: + POST /api/v1/auth/passkey/initiate-passkey-authentication + Body: {} + Response: { options_json: PublicKeyCredentialRequestOptions, code: string } + +2. Replace options_json.rpId with actual hostname (window.location.hostname) + The server returns a sentinel value that must be replaced. + +3. Call platform WebAuthn/FIDO2 API: + - Browser: use WebAuthn library (e.g., @simplewebauthn/browser) + - iOS/Android: use platform passkey APIs + authentication_response = startAuthentication(options_json) + +4. Complete authentication: + POST /api/v1/auth/passkey/sign-in + Body: { authentication_response: , code: string } + Response: { access_token: string, refresh_token: string } + +5. On MFA required: redirect to MFA page +6. Store tokens, redirect to afterSignIn Errors: PasskeyAuthenticationFailed @@ -262,85 +549,296 @@ Errors: PasskeyWebAuthnError code: "passkey_webauthn_error" message: "WebAuthn error: {errorName}." - errorName comes from the WebAuthn API error. + (errorName from WebAuthn/FIDO2 API error) InvalidTotpCode code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + message: "The MFA code is incorrect." ## verifyEmail(code) -code: string - from email verification link +Arguments: + code: string - from email verification link -POST /auth/email-verification/verify { code } -Route: apps/backend/src/app/api/latest/auth/email-verification/verify/route.ts +Returns: void + +Request: + POST /api/v1/contact-channels/verify + Body: { code: string } + +Implementation: +1. Send request +2. Refresh user cache and contact channels cache Errors: - VerificationCodeError (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." ## acceptTeamInvitation(code) -code: string - from team invitation email +Arguments: + code: string - from team invitation email + +Returns: void + +Request: + POST /api/v1/team-invitations/accept [authenticated] + Body: { code: string } + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + +## verifyTeamInvitationCode(code) -POST /teams/invitations/accept { code } [authenticated] -Route: apps/backend/src/app/api/latest/teams/invitations/accept/route.ts +Verifies a team invitation code is valid before accepting. + +Arguments: + code: string - from team invitation email + +Returns: void + +Request: + POST /api/v1/team-invitations/accept/check-code [authenticated] + Body: { code: string } Errors: - VerificationCodeError (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." ## getTeamInvitationDetails(code) -code: string +Arguments: + code: string Returns: { teamDisplayName: string } -POST /teams/invitations/details { code } +Request: + POST /api/v1/team-invitations/accept/details [authenticated] + Body: { code: string } + +Response: + { team_display_name: string } Errors: - VerificationCodeError (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + +## callOAuthCallback() [BROWSER-LIKE] + +Completes the OAuth flow after redirect from OAuth provider. +Call this on the OAuth callback page/handler (urls.oauthCallback). + +Returns: bool + Returns true if OAuth callback was handled and user signed in. + Returns false if no OAuth callback params present (not an OAuth callback). + +Implementation: +1. Get the callback URL from window.location.href + +2. Check URL for OAuth callback params: "code" and "state" + If missing: return false (not an OAuth callback) + +3. Retrieve code verifier using state key from cookie "stack-oauth-outer-{state}" + If not found: return false (callback not for us, or already consumed) + Delete cookie after retrieving. + +4. Remove OAuth params from URL (history.replaceState to hide code) + +5. Exchange authorization code for tokens using OAuth2 authorization_code grant: + Use OAuth library (e.g., oauth4webapi) for proper handling. + + Token endpoint: /api/v1/auth/oauth/token + Grant type: authorization_code + Parameters: + - code: + - redirect_uri: + - code_verifier: + - client_id: + - client_secret: + + Response on success: + { + access_token: string, + refresh_token: string, + is_new_user: bool, + after_callback_redirect_url?: string + } + +6. On MFA required: redirect to MFA page, return false +7. Store tokens { access_token, refresh_token } +8. Redirect to: + - after_callback_redirect_url (if present in response), or + - afterSignUp (if is_new_user), or + - afterSignIn +9. Return true + +Does not return errors - throws on OAuth errors. + +## promptCliLogin(options) [CLI-ONLY] -## callOAuthCallback() [BROWSER-ONLY] +Initiates a CLI authentication flow. Used for authenticating CLI tools. +Opens a browser for the user to sign in, then polls for completion. -Called on the OAuth callback page to complete the flow. +Only available in languages/platforms with an interactive terminal. -Returns: bool - true if successful, false if no callback to handle +Arguments: + options.appUrl: string - base URL of your app (for the login page) + options.expiresInMillis: number? - how long the login attempt is valid + options.maxAttempts: number? - max polling attempts (default: Infinity) + options.waitTimeMillis: number? - time between poll attempts (default: 2000ms) + options.promptLink: function(url: string)? - callback to display login URL to user + +Returns: string - the refresh token for the authenticated session Implementation: -1. Read state and code from URL query params -2. Validate state matches sessionStorage -3. POST /auth/oauth/callback { code, state } -4. On success: store tokens, redirect to afterSignIn/afterSignUp -5. Return true +1. Initiate CLI auth: + POST /api/v1/auth/cli + Body: { expires_in_millis?: number } + Response: { polling_code: string, login_code: string } + +2. Build login URL: {appUrl}/handler/cli?code={login_code} +3. Call promptLink(url) if provided, or open browser to URL + +4. Poll for completion: + POST /api/v1/auth/cli/poll + Body: { polling_code: string } + Response on pending: { status: "pending" } + Response on success: { status: "success", refresh_token: string } + + Poll every waitTimeMillis until success, error, or maxAttempts reached. + +5. Return refresh_token Errors: - InvalidTotpCode - code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + CliAuthError + code: "cli_auth_error" + message: "CLI authentication failed." + + CliAuthExpiredError + code: "cli_auth_expired" + message: "CLI authentication attempt expired. Please try again." + + CliAuthUsedError + code: "cli_auth_used" + message: "This CLI authentication code has already been used." + + +## getItem(options) + +Get a purchased item for a customer. + +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.itemId: string + +Returns: Item + +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/items/{itemId} [authenticated] + + customer_type is "user", "team", or "custom" + customer_id is the corresponding ID + +Response: + { id: string, quantity: number } + +Does not error. + + +## listProducts(options) + +List products available to a customer. + +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.cursor: string? - pagination cursor + options.limit: number? - max results + +Returns: CustomerProductsList + +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/products [authenticated] + Query params: cursor?, limit? + +Response: + { + items: [{ id, name, quantity, ... }], + pagination: { next_cursor?: string } + } + +Does not error. + + +## getConvexClientAuth(options) [JS-ONLY] + +Get auth callback for Convex client integration. + +options.tokenStore: TokenStoreInit? - override token storage + +Returns: function({ forceRefreshToken: bool }) => Promise + +The returned function is passed to Convex's useConvexAuth() hook. +It returns the access token (refreshed if needed) or null if not authenticated. + +Does not error. + + +## getConvexHttpClientAuth(options) [JS-ONLY] + +Get auth token for Convex HTTP client. + +options.tokenStore: TokenStoreInit + +Returns: string - the access token for Convex HTTP requests + +Does not error. ## Redirect Methods All redirect methods take optional { replace?: bool, noRedirectBack?: bool }. -redirectToSignIn() - redirect to signIn URL -redirectToSignUp() - redirect to signUp URL -redirectToSignOut() - redirect to signOut URL -redirectToAfterSignIn() - redirect to afterSignIn URL -redirectToAfterSignUp() - redirect to afterSignUp URL -redirectToAfterSignOut() - redirect to afterSignOut URL -redirectToHome() - redirect to home URL +redirectToSignIn() - redirect to signIn URL +redirectToSignUp() - redirect to signUp URL +redirectToSignOut() - redirect to signOut URL +redirectToAfterSignIn() - redirect to afterSignIn URL +redirectToAfterSignUp() - redirect to afterSignUp URL +redirectToAfterSignOut() - redirect to afterSignOut URL +redirectToHome() - redirect to home URL redirectToAccountSettings() - redirect to accountSettings URL redirectToForgotPassword() - redirect to forgotPassword URL -redirectToPasswordReset() - redirect to passwordReset URL +redirectToPasswordReset() - redirect to passwordReset URL redirectToEmailVerification() - redirect to emailVerification URL -redirectToOnboarding() - redirect to onboarding URL -redirectToError() - redirect to error URL -redirectToMfa() - redirect to mfa URL +redirectToOnboarding() - redirect to onboarding URL +redirectToError() - redirect to error URL +redirectToMfa() - redirect to mfa URL redirectToTeamInvitation() - redirect to teamInvitation URL +redirectToOAuthCallback() - redirect to oauthCallback URL +redirectToMagicLinkCallback() - redirect to magicLinkCallback URL + +Special behavior for signIn/signUp/onboarding: +- If URL has after_auth_return_to query param, preserve it +- Otherwise, set after_auth_return_to to current URL (for redirect after auth) + +Special behavior for afterSignIn/afterSignUp: +- Check URL for after_auth_return_to query param and redirect there instead All require browser or framework-specific redirect capability. Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index dcbeb6d254..130cbc88ba 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -18,12 +18,16 @@ creating users, and accessing server metadata. ## getUser(id) -id: string - user ID to look up +Arguments: + id: string - user ID to look up Returns: ServerUser | null -GET /users/{id} [server-only] -Route: apps/backend/src/app/api/latest/users/[userId]/route.ts +Request: + GET /api/v1/users/{id} [server-only] + +Response: + ServerUserCrud object or 404 if not found Construct ServerUser object (types/users/server-user.spec.md). @@ -32,46 +36,81 @@ Does not error. ## getUser(options: { apiKey }) -options.apiKey: string - API key to authenticate with -options.or: "return-null" | "anonymous"? +Arguments: + options.apiKey: string - API key to authenticate with + options.or: "return-null" | "anonymous"? Returns: ServerUser | null -POST /api-keys/check { api_key } [server-only] +Request: + POST /api/v1/api-keys/check [server-only] + Body: { api_key: string } + +Response: + { user_id?: string, team_id?: string, ... } + Returns user associated with the API key. Does not error. -## getUser(options: { from: "convex", ctx }) +## getUser(options: { from: "convex", ctx }) [JS-ONLY] -options.from: "convex" -options.ctx: ConvexQueryContext - Convex query context -options.or: "return-null" | "anonymous"? +Arguments: + options.from: "convex" + options.ctx: ConvexQueryContext - Convex query context + options.or: "return-null" | "anonymous"? Returns: ServerUser | null Extract token from Convex context, validate, and return user. -For Convex integration. +For Convex integration (JS SDK only). + +Does not error. + + +## getPartialUser(options) + +Get minimal user info without a full API call. +Same as StackClientApp.getPartialUser but returns server user info. + +Arguments: + options.from: "token" | "convex" + - "token": Extract user info from the stored access token + - "convex": Extract user info from Convex auth context [JS-ONLY] + + For "convex" [JS-ONLY]: + options.ctx: ConvexQueryContext - the Convex query context + +Returns: TokenPartialUser | null + +See StackClientApp.getPartialUser for implementation details. Does not error. ## listUsers(options?) -options.cursor: string? - pagination cursor -options.limit: number? - max results (default 100) -options.orderBy: "signedUpAt"? - sort field -options.desc: bool? - descending order -options.query: string? - search query -options.includeRestricted: bool? - include users who haven't completed onboarding -options.includeAnonymous: bool? - include anonymous users +Arguments: + options.cursor: string? - pagination cursor + options.limit: number? - max results (default 100) + options.orderBy: "signedUpAt"? - sort field + options.desc: bool? - descending order + options.query: string? - search query (searches email, display name) + options.includeRestricted: bool? - include users who haven't completed onboarding + options.includeAnonymous: bool? - include anonymous users Returns: ServerUser[] & { nextCursor: string | null } -GET /users [server-only] -Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous -Route: apps/backend/src/app/api/latest/users/route.ts +Request: + GET /api/v1/users [server-only] + Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous + +Response: + { + items: [ServerUserCrud, ...], + pagination: { next_cursor?: string } + } Construct ServerUser for each item. @@ -80,32 +119,51 @@ Does not error. ## createUser(options) -options.primaryEmail: string? -options.primaryEmailAuthEnabled: bool? -options.password: string? -options.otpAuthEnabled: bool? -options.displayName: string? -options.primaryEmailVerified: bool? -options.clientMetadata: json? -options.clientReadOnlyMetadata: json? -options.serverMetadata: json? +Arguments: + options.primaryEmail: string? + options.primaryEmailAuthEnabled: bool? + options.password: string? + options.otpAuthEnabled: bool? + options.displayName: string? + options.primaryEmailVerified: bool? + options.clientMetadata: json? + options.clientReadOnlyMetadata: json? + options.serverMetadata: json? Returns: ServerUser -POST /users { ... } [server-only] -Route: apps/backend/src/app/api/latest/users/route.ts +Request: + POST /api/v1/users [server-only] + Body: { + primary_email?: string, + primary_email_auth_enabled?: bool, + password?: string, + otp_auth_enabled?: bool, + display_name?: string, + primary_email_verified?: bool, + client_metadata?: json, + client_read_only_metadata?: json, + server_metadata?: json + } + +Response: + ServerUserCrud object Does not error. ## getTeam(id) -id: string - team ID +Arguments: + id: string - team ID Returns: ServerTeam | null -GET /teams/{id} [server-only] -Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts +Request: + GET /api/v1/teams/{id} [server-only] + +Response: + ServerTeamCrud object or 404 if not found Construct ServerTeam object (types/teams/server-team.spec.md). @@ -114,11 +172,18 @@ Does not error. ## getTeam(options: { apiKey }) -options.apiKey: string - team API key +Arguments: + options.apiKey: string - team API key Returns: ServerTeam | null -POST /api-keys/check { api_key } [server-only] +Request: + POST /api/v1/api-keys/check [server-only] + Body: { api_key: string } + +Response: + { team_id?: string, ... } + Returns team associated with the API key. Does not error. @@ -128,54 +193,83 @@ Does not error. Returns: ServerTeam[] -GET /teams [server-only] -Route: apps/backend/src/app/api/latest/teams/route.ts +Request: + GET /api/v1/teams [server-only] + +Response: + { items: [ServerTeamCrud, ...] } Does not error. ## createTeam(options) -options.displayName: string -options.profileImageUrl: string? -options.creatorUserId: string? - user to add as creator/member +Arguments: + options.displayName: string + options.profileImageUrl: string? + options.creatorUserId: string? - user to add as creator/member Returns: ServerTeam -POST /teams { display_name, profile_image_url, creator_user_id } [server-only] -Route: apps/backend/src/app/api/latest/teams/route.ts +Request: + POST /api/v1/teams [server-only] + Body: { + display_name: string, + profile_image_url?: string, + creator_user_id?: string + } + +Response: + ServerTeamCrud object Does not error. ## grantProduct(options) -Customer identification (one of): - options.userId: string - options.teamId: string - options.customCustomerId: string - -Product identification (one of): - options.productId: string - existing product ID - options.product: InlineProduct - inline product definition - -options.quantity: number? - default 1 - -POST /customers/{type}/{id}/products { product_id | product, quantity } [server-only] -Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + + Product identification (one of): + options.productId: string - existing product ID + options.product: InlineProduct - inline product definition + + options.quantity: number? - default 1 + +Returns: void + +Request: + POST /api/v1/customers/{customer_type}/{customer_id}/products [server-only] + Body: { + product_id?: string, + product?: { name, description, ... }, + quantity?: number + } Does not error. ## sendEmail(options) -options.to: string | string[] - recipient email(s) -options.subject: string -options.html: string? - HTML body -options.text: string? - plain text body +Arguments: + options.to: string | string[] - recipient email(s) + options.subject: string + options.html: string? - HTML body + options.text: string? - plain text body + +Returns: void -POST /emails { to, subject, html, text } [server-only] -Route: apps/backend/src/app/api/latest/emails/route.ts +Request: + POST /api/v1/emails [server-only] + Body: { + to: string | string[], + subject: string, + html?: string, + text?: string + } Does not error. @@ -184,32 +278,41 @@ Does not error. Returns: EmailDeliveryInfo -GET /emails/delivery-stats [server-only] -Route: apps/backend/src/app/api/latest/emails/delivery-stats/route.ts +Request: + GET /api/v1/emails/delivery-stats [server-only] -Returns: { - delivered: number, - bounced: number, - complained: number, - total: number, -} +Response: + { + delivered: number, + bounced: number, + complained: number, + total: number + } Does not error. ## createOAuthProvider(options) -options.userId: string -options.accountId: string -options.providerConfigId: string -options.email: string -options.allowSignIn: bool -options.allowConnectedAccounts: bool - -Returns: Result - -POST /users/{userId}/oauth-providers { ... } [server-only] -Route: apps/backend/src/app/api/latest/users/[userId]/oauth-providers/route.ts +Arguments: + options.userId: string + options.accountId: string + options.providerConfigId: string + options.email: string + options.allowSignIn: bool + options.allowConnectedAccounts: bool + +Returns: ServerOAuthProvider (on success) + +Request: + POST /api/v1/users/{userId}/oauth-providers [server-only] + Body: { + account_id: string, + provider_config_id: string, + email: string, + allow_sign_in: bool, + allow_connected_accounts: bool + } Errors: OAuthProviderAccountIdAlreadyUsedForSignIn @@ -219,47 +322,65 @@ Errors: ## getDataVaultStore(id) -id: string - data vault store ID +Arguments: + id: string - data vault store ID Returns: DataVaultStore -GET /data-vault/stores/{id} [server-only] - -DataVaultStore has: +DataVaultStore has methods: get(key: string): Promise + GET /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + set(key: string, value: string): Promise + PUT /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Body: { value: string } + delete(key: string): Promise + DELETE /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] Does not error. ## getItem(options) -Customer identification (one of): - options.userId: string - options.teamId: string - options.customCustomerId: string - -options.itemId: string +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.itemId: string Returns: ServerItem -GET /customers/{type}/{id}/items/{itemId} [server-only] -Route: apps/backend/src/app/api/latest/customers/[...]/items/[itemId]/route.ts +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/items/{itemId} [server-only] -ServerItem has: - id: string - quantity: number +Response: + { id: string, quantity: number } Does not error. ## listProducts(options) -options: CustomerProductsRequestOptions +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.cursor: string? - pagination cursor + options.limit: number? - max results Returns: CustomerProductsList -GET /customers/{type}/{id}/products [server-only] +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/products [server-only] + Query params: cursor?, limit? + +Response: + { + items: [{ id, name, quantity, ... }], + pagination: { next_cursor?: string } + } Does not error. diff --git a/sdks/spec/src/types/auth/oauth-connection.spec.md b/sdks/spec/src/types/auth/oauth-connection.spec.md index dcbf0e0b85..c7d55fc142 100644 --- a/sdks/spec/src/types/auth/oauth-connection.spec.md +++ b/sdks/spec/src/types/auth/oauth-connection.spec.md @@ -16,7 +16,7 @@ id: string Returns: string -POST /connected-accounts/{id}/access-token {} [authenticated] +POST /api/v1/connected-accounts/{id}/access-token {} [authenticated] Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts Returns a fresh OAuth access token for the connected account. @@ -71,7 +71,7 @@ options: { Returns: Result -PATCH /users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated] +PATCH /api/v1/users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated] Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts Errors (in Result): @@ -82,7 +82,7 @@ Errors (in Result): ### delete() -DELETE /users/me/oauth-providers/{id} [authenticated] +DELETE /api/v1/users/me/oauth-providers/{id} [authenticated] Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts Does not error. @@ -113,7 +113,7 @@ options: { Returns: Result -PATCH /users/{userId}/oauth-providers/{id} [server-only] +PATCH /api/v1/users/{userId}/oauth-providers/{id} [server-only] Body: { account_id, email, allow_sign_in, allow_connected_accounts } Errors (in Result): @@ -124,6 +124,6 @@ Errors (in Result): ### delete() -DELETE /users/{userId}/oauth-providers/{id} [server-only] +DELETE /api/v1/users/{userId}/oauth-providers/{id} [server-only] Does not error. diff --git a/sdks/spec/src/types/contact-channels/contact-channel.spec.md b/sdks/spec/src/types/contact-channels/contact-channel.spec.md index 55405ca7ce..873fa1813d 100644 --- a/sdks/spec/src/types/contact-channels/contact-channel.spec.md +++ b/sdks/spec/src/types/contact-channels/contact-channel.spec.md @@ -31,7 +31,7 @@ usedForAuth: bool options.callbackUrl: string? - URL to redirect after verification -POST /contact-channels/{id}/send-verification-email { callback_url } [authenticated] +POST /api/v1/contact-channels/{id}/send-verification-email { callback_url } [authenticated] Route: apps/backend/src/app/api/latest/contact-channels/[id]/send-verification-email/route.ts Sends a verification email to this contact channel. @@ -47,7 +47,7 @@ options: { isPrimary?: bool, } -PATCH /contact-channels/{id} { value, used_for_auth, is_primary } [authenticated] +PATCH /api/v1/contact-channels/{id} { value, used_for_auth, is_primary } [authenticated] Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts Does not error. @@ -55,7 +55,7 @@ Does not error. ### delete() -DELETE /contact-channels/{id} [authenticated] +DELETE /api/v1/contact-channels/{id} [authenticated] Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts Does not error. @@ -82,7 +82,7 @@ options: { isVerified?: bool, // Server can directly set verification status } -PATCH /contact-channels/{id} [server-only] +PATCH /api/v1/contact-channels/{id} [server-only] Body: { value, used_for_auth, is_primary, is_verified } Does not error. diff --git a/sdks/spec/src/types/payments/customer.spec.md b/sdks/spec/src/types/payments/customer.spec.md index e2c92c6f17..64ff376697 100644 --- a/sdks/spec/src/types/payments/customer.spec.md +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -19,7 +19,7 @@ options.returnUrl: string? - URL to redirect after checkout Returns: string (checkout URL) -POST /customers/{type}/{id}/checkout { product_id, return_url } [authenticated] +POST /api/v1/customers/{type}/{id}/checkout { product_id, return_url } [authenticated] Route: apps/backend/src/app/api/latest/customers/[...]/checkout/route.ts Returns a Stripe checkout URL for purchasing the product. @@ -31,7 +31,7 @@ Does not error. Returns: CustomerBilling -GET /customers/{type}/{id}/billing [authenticated] +GET /api/v1/customers/{type}/{id}/billing [authenticated] Route: apps/backend/src/app/api/latest/customers/[...]/billing/route.ts CustomerBilling has: @@ -52,7 +52,7 @@ Does not error. Returns: CustomerPaymentMethodSetupIntent -POST /customers/{type}/{id}/payment-method-setup-intent [authenticated] +POST /api/v1/customers/{type}/{id}/payment-method-setup-intent [authenticated] CustomerPaymentMethodSetupIntent has: clientSecret: string - for Stripe.js to confirm setup @@ -67,7 +67,7 @@ setupIntentId: string Returns: CustomerDefaultPaymentMethod -POST /customers/{type}/{id}/default-payment-method { setup_intent_id } [authenticated] +POST /api/v1/customers/{type}/{id}/default-payment-method { setup_intent_id } [authenticated] After user completes payment method setup via Stripe.js, call this to set it as default. @@ -81,7 +81,7 @@ itemId: string Returns: Item -GET /customers/{type}/{id}/items/{itemId} [authenticated] +GET /api/v1/customers/{type}/{id}/items/{itemId} [authenticated] Item has: displayName: string @@ -95,7 +95,7 @@ Does not error. Returns: Item[] -GET /customers/{type}/{id}/items [authenticated] +GET /api/v1/customers/{type}/{id}/items [authenticated] Does not error. @@ -129,7 +129,7 @@ options.limit: number? Returns: CustomerProductsList -GET /customers/{type}/{id}/products [authenticated] +GET /api/v1/customers/{type}/{id}/products [authenticated] Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts CustomerProductsList is CustomerProduct[] with: @@ -145,7 +145,7 @@ options.toProductId: string - target subscription product ID options.priceId: string? - specific price of target product options.quantity: number? -POST /customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated] +POST /api/v1/customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated] For switching between subscription plans. @@ -224,7 +224,7 @@ Extends: Item amount: number (positive) -POST /customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] +POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] Does not error. @@ -233,7 +233,7 @@ Does not error. amount: number (positive) -POST /customers/{type}/{id}/items/{itemId}/quantity { change: -amount } [server-only] +POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: -amount } [server-only] Note: Quantity may go negative. Use tryDecreaseQuantity for atomic decrement-if-positive. @@ -246,7 +246,7 @@ amount: number (positive) Returns: bool -POST /customers/{type}/{id}/items/{itemId}/try-decrease { amount } [server-only] +POST /api/v1/customers/{type}/{id}/items/{itemId}/try-decrease { amount } [server-only] Returns true if quantity was >= amount and was decreased. Returns false if quantity would go negative (no change made). diff --git a/sdks/spec/src/types/payments/item.spec.md b/sdks/spec/src/types/payments/item.spec.md index b900085d73..f891b98e98 100644 --- a/sdks/spec/src/types/payments/item.spec.md +++ b/sdks/spec/src/types/payments/item.spec.md @@ -51,7 +51,7 @@ Extends: Item amount: number (positive) -POST /internal/items/quantity-changes { +POST /api/v1/internal/items/quantity-changes { user_id | team_id | custom_customer_id, item_id, quantity: amount @@ -66,7 +66,7 @@ Does not error. amount: number (positive) -POST /internal/items/quantity-changes { +POST /api/v1/internal/items/quantity-changes { user_id | team_id | custom_customer_id, item_id, quantity: -amount @@ -85,7 +85,7 @@ amount: number (positive) Returns: bool -POST /internal/items/try-decrease { +POST /api/v1/internal/items/try-decrease { user_id | team_id | custom_customer_id, item_id, amount diff --git a/sdks/spec/src/types/permissions/permission.spec.md b/sdks/spec/src/types/permissions/permission.spec.md index e5b8da1a5a..574f1e3905 100644 --- a/sdks/spec/src/types/permissions/permission.spec.md +++ b/sdks/spec/src/types/permissions/permission.spec.md @@ -9,38 +9,6 @@ id: string The permission identifier (e.g., "read", "write", "admin"). ---- - -# AdminTeamPermission - -Admin view of a team permission. Same as TeamPermission. - -Extends: TeamPermission - - ---- - -# AdminTeamPermissionDefinition - -Definition of a team permission that can be granted. - - -## Properties - -id: string - Unique permission identifier. - -description: string? - Human-readable description of what this permission allows. - -containedPermissionIds: string[] - List of other permission IDs that are implied by this permission. - For hierarchical permissions (e.g., "admin" contains "write" and "read"). - -isDefaultUserPermission: bool? - Whether this permission is granted by default to new team members. - - --- # ProjectPermission @@ -52,122 +20,3 @@ A project-level permission granted to a user. id: string The permission identifier. - - ---- - -# AdminProjectPermission - -Admin view of a project permission. Same as ProjectPermission. - -Extends: ProjectPermission - - ---- - -# AdminProjectPermissionDefinition - -Definition of a project-level permission. - - -## Properties - -id: string - Unique permission identifier. - -description: string? - Human-readable description. - -containedPermissionIds: string[] - List of implied permission IDs. - - ---- - -# Permission Definition CRUD (Admin only) - - -## Team Permission Definitions - -### Create - -createTeamPermissionDefinition(options) - -options.id: string -options.description: string? -options.containedPermissionIds: string[] -options.isDefaultUserPermission: bool? - -POST /team-permission-definitions { id, description, contained_permission_ids } [admin-only] -Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts - - -### Update - -updateTeamPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? -options.containedPermissionIds: string[]? - -PATCH /team-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] - - -### Delete - -deleteTeamPermissionDefinition(permissionId) - -permissionId: string - -DELETE /team-permission-definitions/{permissionId} [admin-only] - - -### List - -listTeamPermissionDefinitions() - -Returns: AdminTeamPermissionDefinition[] - -GET /team-permission-definitions [admin-only] - - -## Project Permission Definitions - -### Create - -createProjectPermissionDefinition(options) - -options.id: string -options.description: string? -options.containedPermissionIds: string[] - -POST /project-permission-definitions { id, description, contained_permission_ids } [admin-only] - - -### Update - -updateProjectPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? -options.containedPermissionIds: string[]? - -PATCH /project-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] - - -### Delete - -deleteProjectPermissionDefinition(permissionId) - -permissionId: string - -DELETE /project-permission-definitions/{permissionId} [admin-only] - - -### List - -listProjectPermissionDefinitions() - -Returns: AdminProjectPermissionDefinition[] - -GET /project-permission-definitions [admin-only] diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md index 86be310499..92ce9e69b8 100644 --- a/sdks/spec/src/types/projects/project.spec.md +++ b/sdks/spec/src/types/projects/project.spec.md @@ -45,157 +45,3 @@ clientTeamCreationEnabled: bool clientUserDeletionEnabled: bool Whether clients can delete their own accounts. - - ---- - -# AdminProject - -Full project information with admin capabilities. - -Extends: Project - - -## Additional Properties - -description: string | null - Project description. - -createdAt: Date - When the project was created. - -isProductionMode: bool - Whether project is in production mode. - -ownerTeamId: string | null - The team that owns this project. - -logoUrl: string | null - URL to project logo. - -logoFullUrl: string | null - URL to full-size project logo. - -logoDarkModeUrl: string | null - URL to dark mode logo. - -logoFullDarkModeUrl: string | null - URL to full-size dark mode logo. - -config: AdminProjectConfig - Full project configuration (extends ProjectConfig with sensitive settings). - - -## Methods - - -### update(options) - -options: { - displayName?: string, - description?: string, - isProductionMode?: bool, - logoUrl?: string | null, - logoFullUrl?: string | null, - logoDarkModeUrl?: string | null, - logoFullDarkModeUrl?: string | null, - config?: AdminProjectConfigUpdateOptions, -} - -PATCH /projects/current [admin-only] -Route: apps/backend/src/app/api/latest/projects/current/route.ts - -Does not error. - - -### delete() - -DELETE /projects/current [admin-only] - -Does not error. - - -### getConfig() - -Returns: CompleteConfig - -GET /projects/current/config [admin-only] - -Returns the full normalized project configuration. - -Does not error. - - -### updateConfig(config) - -config: EnvironmentConfigOverride - Use path notation to update nested properties (e.g., { "emails.server.host": "..." }) - Do NOT pass full top-level objects as they will overwrite siblings. - -PATCH /projects/current/config { ...pathUpdates } [admin-only] - -Does not error. - - -### getProductionModeErrors() - -Returns: ProductionModeError[] - -GET /projects/current/production-mode-errors [admin-only] - -Returns a list of issues that would prevent production mode. - -ProductionModeError has: - type: string - message: string - -Does not error. - - ---- - -# AdminProjectConfig - -Extended project configuration with admin-only settings. - -Extends: ProjectConfig - - -## Additional Properties - -domains: DomainConfig[] - Trusted domains configuration. - Each has: domain: string, handlerPath: string - -emailConfig: EmailConfig - Email sending configuration. - Either: { type: "shared" } - use Stack's shared email - Or: { type: "standard", host, port, username, password, senderName, senderEmail } - -allowLocalhost: bool - Whether localhost is allowed (for development). - -createTeamOnSignUp: bool - Whether to create a team for each new user. - -teamCreatorDefaultPermissions: string[] - Default permissions for team creators. - -teamMemberDefaultPermissions: string[] - Default permissions for team members. - -userDefaultPermissions: string[] - Default project-level permissions for users. - -oauthAccountMergeStrategy: "link" | "prevent" - How to handle OAuth accounts with existing emails. - -allowUserApiKeys: bool - Whether users can create API keys. - -allowTeamApiKeys: bool - Whether teams can create API keys. - -oauthProviders: AdminOAuthProviderConfig[] - Full OAuth provider configs including secrets. - Each has: id, type, clientId, clientSecret, and provider-specific fields. diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md index a7571ef90a..f0caf67f3b 100644 --- a/sdks/spec/src/types/teams/server-team.spec.md +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -27,7 +27,7 @@ options: { serverMetadata?: json, } -PATCH /teams/{teamId} [server-only] +PATCH /api/v1/teams/{teamId} [server-only] Body: { display_name, profile_image_url, client_metadata, client_read_only_metadata, server_metadata } Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts @@ -38,7 +38,7 @@ Does not error. Returns: ServerTeamUser[] -GET /teams/{teamId}/users [server-only] +GET /api/v1/teams/{teamId}/users [server-only] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts ServerTeamUser extends ServerUser with: @@ -51,7 +51,7 @@ Does not error. userId: string -POST /teams/{teamId}/users { user_id } [server-only] +POST /api/v1/teams/{teamId}/users { user_id } [server-only] Directly adds a user to the team without invitation. @@ -62,7 +62,7 @@ Does not error. userId: string -DELETE /teams/{teamId}/users/{userId} [server-only] +DELETE /api/v1/teams/{teamId}/users/{userId} [server-only] Does not error. @@ -72,13 +72,13 @@ Does not error. options.email: string options.callbackUrl: string? -POST /teams/{teamId}/invitations { email, callback_url } [server-only] +POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [server-only] Does not error. ### delete() -DELETE /teams/{teamId} [server-only] +DELETE /api/v1/teams/{teamId} [server-only] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 5db8f0bd60..9cb856bd96 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -32,7 +32,7 @@ options: { clientMetadata?: json, } -PATCH /teams/{teamId} [authenticated] +PATCH /api/v1/teams/{teamId} [authenticated] Body: { display_name, profile_image_url, client_metadata } Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts @@ -41,7 +41,7 @@ Does not error. ### delete() -DELETE /teams/{teamId} [authenticated] +DELETE /api/v1/teams/{teamId} [authenticated] Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts Does not error. @@ -52,7 +52,7 @@ Does not error. options.email: string options.callbackUrl: string? -POST /teams/{teamId}/invitations { email, callback_url } [authenticated] +POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [authenticated] Sends invitation email to the specified address. @@ -63,7 +63,7 @@ Does not error. Returns: TeamUser[] -GET /teams/{teamId}/users [authenticated] +GET /api/v1/teams/{teamId}/users [authenticated] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts TeamUser has: @@ -81,7 +81,7 @@ Does not error. Returns: TeamInvitation[] -GET /teams/{teamId}/invitations [authenticated] +GET /api/v1/teams/{teamId}/invitations [authenticated] TeamInvitation has: id: string @@ -100,7 +100,7 @@ options.scope: string? Returns: TeamApiKeyFirstView -POST /teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] +POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] TeamApiKeyFirstView extends TeamApiKey with: apiKey: string - the actual key value (only shown once) @@ -112,7 +112,7 @@ Does not error. Returns: TeamApiKey[] -GET /teams/{teamId}/api-keys [authenticated] +GET /api/v1/teams/{teamId}/api-keys [authenticated] TeamApiKey has: id: string diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index db22f1b27b..d158d69de4 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -36,7 +36,7 @@ options: { totpMultiFactorSecret?: bytes | null, } -PATCH /users/me [authenticated] +PATCH /api/v1/users/me [authenticated] Body: only include provided fields, convert to snake_case Route: apps/backend/src/app/api/latest/users/me/route.ts @@ -47,7 +47,7 @@ Does not error. ## delete() -DELETE /users/me [authenticated] +DELETE /api/v1/users/me [authenticated] Route: apps/backend/src/app/api/latest/users/me/route.ts Clear stored tokens after success. @@ -78,7 +78,9 @@ Does not error. options.oldPassword: string options.newPassword: string -PATCH /users/me { old_password, new_password } [authenticated] +Returns: void + +POST /api/v1/auth/password/update { old_password, new_password } [authenticated] Errors: PasswordConfirmationMismatch @@ -94,7 +96,9 @@ Errors: options.password: string -POST /users/me/password { password } [authenticated] +Returns: void + +POST /api/v1/auth/password/set { password } [authenticated] For users without existing password (OAuth-only, anonymous). @@ -111,7 +115,7 @@ Errors: Returns: Team[] -GET /users/me/teams [authenticated] +GET /api/v1/users/me/teams [authenticated] Route: apps/backend/src/app/api/latest/users/me/teams/route.ts Construct Team for each item. @@ -137,7 +141,7 @@ options.profileImageUrl: string? Returns: Team -POST /teams { display_name, profile_image_url, creator_user_id: "me" } [authenticated] +POST /api/v1/teams { display_name, profile_image_url, creator_user_id: "me" } [authenticated] Route: apps/backend/src/app/api/latest/teams/route.ts Then select the new team via update({ selectedTeamId: newTeam.id }). @@ -158,7 +162,7 @@ Does not error. team: Team -DELETE /teams/{teamId}/users/me [authenticated] +DELETE /api/v1/teams/{teamId}/users/me [authenticated] Does not error. @@ -169,7 +173,7 @@ team: Team Returns: EditableTeamMemberProfile -GET /teams/{teamId}/users/me/profile [authenticated] +GET /api/v1/teams/{teamId}/users/me/profile [authenticated] EditableTeamMemberProfile has: displayName: string | null @@ -186,7 +190,7 @@ Does not error. Returns: ContactChannel[] -GET /contact-channels [authenticated] +GET /api/v1/contact-channels [authenticated] Route: apps/backend/src/app/api/latest/contact-channels/route.ts Does not error. @@ -201,7 +205,7 @@ options.isPrimary: bool? Returns: ContactChannel -POST /contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] +POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] Does not error. @@ -213,7 +217,7 @@ Does not error. Returns: OAuthProvider[] -GET /users/me/oauth-providers [authenticated] +GET /api/v1/users/me/oauth-providers [authenticated] Route: apps/backend/src/app/api/latest/users/me/oauth-providers/route.ts OAuthProvider has: @@ -224,7 +228,11 @@ OAuthProvider has: email: string? allowSignIn: bool allowConnectedAccounts: bool - update(data): Promise> + update(data): Promise + Errors: + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user." delete(): Promise Does not error. @@ -246,22 +254,39 @@ Does not error. ### getConnectedAccount(providerId, options?) +Get access to a connected OAuth account for API calls to third-party services. +For example, get a Google access token to call Google APIs on behalf of the user. + providerId: string (e.g., "google", "github") -options.scopes: string[]? - required OAuth scopes +options.scopes: string[]? - required OAuth scopes for the access token options.or: "redirect" | "throw" | "return-null" Default: "return-null" Returns: OAuthConnection | null -POST /connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated] -Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts - -On success: return OAuthConnection with { id, getAccessToken() } - -On error "oauth_scope_not_granted" or "oauth_connection_not_connected": - - or="redirect": redirect to OAuth flow with additional scopes [BROWSER-ONLY] - - or="throw": throw the error - - or="return-null": return null +Implementation: +1. Check if user has the OAuth provider connected: + Look for providerId in user.oauthProviders + If not found and or="redirect": go to step 4 + If not found otherwise: handle as "not connected" (see below) + +2. Request an access token with the required scopes: + POST /api/v1/connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated] + Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts + +3. On success: return OAuthConnection { id: providerId, getAccessToken() } + The getAccessToken() method returns the token from step 2 (cached, refreshed as needed) + +4. On error "oauth_scope_not_granted" or "oauth_connection_not_connected": + - or="redirect" [BROWSER-LIKE]: + Start OAuth flow to connect/add scopes: + - Use same PKCE flow as signInWithOAuth + - Set type="link" instead of "authenticate" + - Include afterCallbackRedirectUrl = current page URL + - Merge requested scopes with any scopes from oauthScopesOnSignIn config + - Never returns (browser redirects) + - or="throw": throw the error + - or="return-null": return null Errors (only when or="throw"): OAuthConnectionNotConnectedToUser @@ -283,7 +308,7 @@ permissionId: string Returns: bool -GET /users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated] +GET /api/v1/users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated] Does not error. @@ -307,7 +332,7 @@ options.recursive: bool? - include inherited permissions Returns: TeamPermission[] -GET /users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] +GET /api/v1/users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] Does not error. @@ -319,7 +344,7 @@ Does not error. Returns: ActiveSession[] -GET /users/me/sessions [authenticated] +GET /api/v1/users/me/sessions [authenticated] ActiveSession has: id: string @@ -337,7 +362,7 @@ Does not error. sessionId: string -DELETE /users/me/sessions/{sessionId} [authenticated] +DELETE /api/v1/users/me/sessions/{sessionId} [authenticated] Does not error. @@ -345,20 +370,20 @@ Does not error. ## Passkey Methods -### registerPasskey(options?) [BROWSER-ONLY] +### registerPasskey(options?) [BROWSER-LIKE] options.hostname: string? -Returns: Result +Returns: void Implementation: -1. POST /auth/passkey/register/initiate {} [authenticated] +1. POST /api/v1/auth/passkey/initiate-passkey-registration {} [authenticated] Response: { options_json, code } 2. Replace options_json.rp.id with actual hostname 3. Call WebAuthn startRegistration(options_json) -4. POST /auth/passkey/register { credential, code } [authenticated] +4. POST /api/v1/auth/passkey/register { credential, code } [authenticated] -Errors (in Result): +Errors: PasskeyRegistrationFailed code: "passkey_registration_failed" message: "Failed to register passkey. Please try again." @@ -375,7 +400,7 @@ Errors (in Result): Returns: UserApiKey[] -GET /users/me/api-keys [authenticated] +GET /api/v1/users/me/api-keys [authenticated] Does not error. @@ -389,7 +414,7 @@ options.teamId: string? - for team-scoped keys Returns: UserApiKeyFirstView -POST /users/me/api-keys { description, expires_at, scope, team_id } [authenticated] +POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] UserApiKeyFirstView extends UserApiKey with: apiKey: string - the actual key value (only shown once) @@ -404,7 +429,7 @@ Does not error. Returns: NotificationCategory[] -GET /notification-categories [authenticated] +GET /api/v1/notification-categories [authenticated] Does not error. diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md index 5cff0d3323..d3206321d8 100644 --- a/sdks/spec/src/types/users/server-user.spec.md +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -36,7 +36,7 @@ options: { totpMultiFactorSecret?: bytes | null, } -PATCH /users/{userId} [server-only] +PATCH /api/v1/users/{userId} [server-only] Body: only include provided fields, convert to snake_case Route: apps/backend/src/app/api/latest/users/[userId]/route.ts @@ -81,7 +81,7 @@ options.profileImageUrl: string? Returns: ServerTeam -POST /teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] +POST /api/v1/teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] Does not error. @@ -90,7 +90,7 @@ Does not error. Returns: ServerTeam[] -GET /users/{userId}/teams [server-only] +GET /api/v1/users/{userId}/teams [server-only] Does not error. @@ -113,7 +113,7 @@ Does not error. Returns: ServerContactChannel[] -GET /users/{userId}/contact-channels [server-only] +GET /api/v1/users/{userId}/contact-channels [server-only] ServerContactChannel extends ContactChannel with: update(data: ServerContactChannelUpdateOptions): Promise @@ -134,7 +134,7 @@ options.isVerified: bool? Returns: ServerContactChannel -POST /contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only] +POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only] Does not error. @@ -147,7 +147,7 @@ Does not error. scope: Team? - if omitted, grants project-level permission permissionId: string -POST /users/{userId}/permissions { team_id, permission_id } [server-only] +POST /api/v1/users/{userId}/permissions { team_id, permission_id } [server-only] Does not error. @@ -157,7 +157,7 @@ Does not error. scope: Team? permissionId: string -DELETE /users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] +DELETE /api/v1/users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] Does not error. @@ -169,7 +169,7 @@ permissionId: string Returns: bool -GET /users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only] +GET /api/v1/users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only] Does not error. @@ -189,9 +189,9 @@ Does not error. scope: Team? options.direct: bool? - only directly assigned, not inherited -Returns: AdminTeamPermission[] +Returns: TeamPermission[] -GET /users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] +GET /api/v1/users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] Does not error. @@ -203,7 +203,7 @@ Does not error. Returns: ServerOAuthProvider[] -GET /users/{userId}/oauth-providers [server-only] +GET /api/v1/users/{userId}/oauth-providers [server-only] ServerOAuthProvider extends OAuthProvider with: accountId: string (always present, not optional) @@ -231,7 +231,7 @@ options.isImpersonation: bool? - mark as impersonation session Returns: { getTokens(): Promise<{ accessToken, refreshToken }> } -POST /users/{userId}/sessions { expires_in_millis, is_impersonation } [server-only] +POST /api/v1/users/{userId}/sessions { expires_in_millis, is_impersonation } [server-only] Creates a new session for this user. Can be used to impersonate them. @@ -262,7 +262,7 @@ Also includes all methods from CurrentUser that are applicable: - listPermissions(scope?, options?) - getActiveSessions() - revokeSession(sessionId) -- registerPasskey(options?) [BROWSER-ONLY] +- registerPasskey(options?) [BROWSER-LIKE] - listApiKeys() - createApiKey(options) - listNotificationCategories() From e5f80ce3942cb0a24001bb6402cead546bfc3f5d Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 10:44:26 -0800 Subject: [PATCH 03/17] Finish specs --- sdks/spec/README.md | 30 +++++ sdks/spec/src/_utilities.spec.md | 66 ++++++++++- sdks/spec/src/apps/client-app.spec.md | 89 ++++++++++----- sdks/spec/src/apps/server-app.spec.md | 20 +++- .../src/types/auth/oauth-connection.spec.md | 8 +- sdks/spec/src/types/common/api-keys.spec.md | 107 ++++++++++++++++++ sdks/spec/src/types/common/sessions.spec.md | 55 +++++++++ .../notification-category.spec.md | 42 +++++++ sdks/spec/src/types/payments/customer.spec.md | 25 ++++ sdks/spec/src/types/projects/project.spec.md | 8 +- sdks/spec/src/types/teams/server-team.spec.md | 5 +- .../types/teams/team-member-profile.spec.md | 66 +++++++++++ sdks/spec/src/types/teams/team.spec.md | 31 +++-- .../spec/src/types/users/current-user.spec.md | 85 ++++++++++---- 14 files changed, 556 insertions(+), 81 deletions(-) create mode 100644 sdks/spec/src/types/common/api-keys.spec.md create mode 100644 sdks/spec/src/types/common/sessions.spec.md create mode 100644 sdks/spec/src/types/notifications/notification-category.spec.md create mode 100644 sdks/spec/src/types/teams/team-member-profile.spec.md diff --git a/sdks/spec/README.md b/sdks/spec/README.md index 3b029df45b..223abd3c04 100644 --- a/sdks/spec/README.md +++ b/sdks/spec/README.md @@ -2,6 +2,8 @@ This folder contains the specification for Stack Auth's SDKs. +When writing this specification, try to write imperative pseudocode as much as possible (be explicit about what things are named, etc.). + ## Notation The spec files use the following notation: @@ -30,3 +32,31 @@ The languages should adapt: - **Parameter conventions**: Objects vs. kwargs, etc. - **Framework hooks**: Eg. for React, add `use*` equivalents to `get*`/`list*` methods - **Everything else, wherever it makes sense**: Every language is unique and the patterns will differ. If you have to decide between what's idiomatic in a language vs. what was done in the Stack Auth SDK for other languages, use the idiomatic pattern. + +## Implementation Notes + +### Object Construction + +When constructing SDK objects (User, Team, etc.) from API responses: +1. Map naming conventions to your language's naming convention +2. Objects should hold a reference to the SDK client for making API calls +3. Objects can be mutable or immutable based on language conventions +4. `update()` methods should update local properties after successful API call + +### Caching + +Normal functions should not cache. Some frameworks, like React, have hooks that require caching; for these, require explicit guidance. + +### Pagination + +Most `list*` methods support pagination: +- Request with `cursor` and `limit` query params +- Response includes `pagination: { next_cursor?: string }` +- `next_cursor` is null or absent when no more pages +- Default limit is typically 100 +- Note that not all backend APIs support pagination, and some just return all items at once. + +### Date/Time Formats + +- API uses milliseconds since epoch for timestamps (e.g., `signed_up_at_millis`) +- Convert to your language's native Date/DateTime type diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 1c5d74b32e..47152ab71f 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -48,7 +48,11 @@ On 401 response with code="invalid_access_token": ### Token Refresh -Use OAuth2 refresh_token grant to get new access token: +Use OAuth2 refresh_token grant to get new access token. + +Concurrency: Token refresh must be serialized. Only one refresh request should be in-flight at a time. +If a refresh is already in progress, wait for it to complete rather than starting another. +Use a mutex/lock to ensure this (or, if preferred in that framework, some kind of asynchronous mechanism that doesn't block the main thread). POST /api/v1/auth/oauth/token Content-Type: application/x-www-form-urlencoded @@ -62,7 +66,8 @@ Body (form-encoded): Response on success: { access_token: string, refresh_token?: string, ... } -On error (e.g., refresh_token_error): clear tokens, user is signed out. +On success: store new access_token. If refresh_token is returned, store it too. +On error (e.g., refresh_token_error): clear all tokens, user is signed out. Use an OAuth library (e.g., oauth4webapi) for proper OAuth2 handling. @@ -140,6 +145,19 @@ Store access_token and refresh_token. The tokenStore constructor option determin Many functions also accept a tokenStore parameter to override storage for that call. +### TokenStoreInit Type + +TokenStoreInit is a union type representing the different ways to provide token storage: + +``` +TokenStoreInit = + | "cookie" // [JS-ONLY] Browser cookies + | "memory" // In-memory storage + | { accessToken: string, refreshToken: string } // Explicit tokens + | RequestLike // Extract from request headers + | null // No storage +``` + ### Token Store Types "cookie": [JS-ONLY] @@ -155,8 +173,19 @@ Many functions also accept a tokenStore parameter to override storage for that c Use explicit token values directly. For custom token management scenarios. +RequestLike object: + An object that conforms to whatever the requests look like in common backend frameworks. For example, in JavaScript, these often have the shape `{ headers: { get(name: string): string | null } }`, but in other languages this may drastically differ (and may not even be an interface and instead rather just be an abstract class, or not exist at all). + + This exists as a simplified way to support common backend frameworks in a more accessible way than the `{ accessToken: string, refreshToken: string }` one. + + Extract tokens from the x-stack-auth header: + 1. Get header value: headers.get("x-stack-auth") + 2. Parse as JSON: { accessToken: string, refreshToken: string } + 3. Use those tokens for authentication + null: - No token storage. SDK methods requiring authentication will fail. Most useful for backends, as you can still specify the token store per-request. + No token storage. SDK methods requiring authentication will fail. + Most useful for backends, as you can still specify the token store per-request. ### x-stack-auth Header Format @@ -188,4 +217,33 @@ Methods that can return this error: - signInWithPasskey - callOAuthCallback -The attempt_code is short-lived and single-use. +The attempt_code is short-lived (a few minutes) and single-use. + + +## JWT Access Token Claims + +The access token is a JWT with these claims: + +| Claim | Maps to | Type | +|-------|---------|------| +| sub | id | string | +| name | displayName | string or null | +| email | primaryEmail | string or null | +| email_verified | primaryEmailVerified | boolean | +| is_anonymous | isAnonymous | boolean | +| is_restricted | isRestricted | boolean | +| restricted_reason | restrictedReason | object or null | +| exp | expiresAt | number (Unix timestamp) | +| iat | issuedAt | number (Unix timestamp) | + +To decode: split by ".", base64url-decode the second segment, parse as JSON. + + +## Unknown Errors + +If an API returns an error code not listed in the spec: +1. Create a generic StackAuthApiError with the code and message +2. Log the unknown error for debugging +3. Treat it as a general API error + +This ensures forward compatibility when new error codes are added. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 0a187c91ba..9a590851cd 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -22,12 +22,24 @@ Optional: "cookie" is JS-only due to complexity. See _utilities.spec.md for details. urls: object - Override handler URLs. Defaults under "/handler": + Override handler URLs. Defaults: + home: "/" signIn: "/handler/sign-in" signUp: "/handler/sign-up" + signOut: "/handler/sign-out" afterSignIn: "/" afterSignUp: "/" - ... see apps/backend for full list + afterSignOut: "/" + emailVerification: "/handler/email-verification" + passwordReset: "/handler/password-reset" + forgotPassword: "/handler/forgot-password" + magicLinkCallback: "/handler/magic-link-callback" + oauthCallback: "/handler/oauth-callback" + accountSettings: "/handler/account-settings" + onboarding: "/handler/onboarding" + teamInvitation: "/handler/team-invitation" + mfa: "/handler/mfa" + error: "/handler/error" oauthScopesOnSignIn: object Additional OAuth scopes to request during sign-in for each provider. @@ -264,10 +276,11 @@ Response: credential_enabled: bool, magic_link_enabled: bool, passkey_enabled: bool, - oauth_providers: [{ id: string, type: string }], + oauth_providers: [{ id: string }], client_team_creation_enabled: bool, client_user_deletion_enabled: bool, - domains: [{ domain: string, handler_path: string }] + allow_user_api_keys: bool, + allow_team_api_keys: bool } } @@ -813,32 +826,48 @@ Does not error. ## Redirect Methods -All redirect methods take optional { replace?: bool, noRedirectBack?: bool }. - -redirectToSignIn() - redirect to signIn URL -redirectToSignUp() - redirect to signUp URL -redirectToSignOut() - redirect to signOut URL -redirectToAfterSignIn() - redirect to afterSignIn URL -redirectToAfterSignUp() - redirect to afterSignUp URL -redirectToAfterSignOut() - redirect to afterSignOut URL -redirectToHome() - redirect to home URL -redirectToAccountSettings() - redirect to accountSettings URL -redirectToForgotPassword() - redirect to forgotPassword URL -redirectToPasswordReset() - redirect to passwordReset URL -redirectToEmailVerification() - redirect to emailVerification URL -redirectToOnboarding() - redirect to onboarding URL -redirectToError() - redirect to error URL -redirectToMfa() - redirect to mfa URL -redirectToTeamInvitation() - redirect to teamInvitation URL -redirectToOAuthCallback() - redirect to oauthCallback URL -redirectToMagicLinkCallback() - redirect to magicLinkCallback URL - -Special behavior for signIn/signUp/onboarding: -- If URL has after_auth_return_to query param, preserve it -- Otherwise, set after_auth_return_to to current URL (for redirect after auth) - -Special behavior for afterSignIn/afterSignUp: -- Check URL for after_auth_return_to query param and redirect there instead +All redirect methods take optional options: + +Options: + replace: bool? - if true, replace current history entry instead of pushing + - Browser: use location.replace() instead of location.assign() + - Mobile: affects navigation stack behavior + noRedirectBack: bool? - if true, don't set after_auth_return_to param + +Methods: + redirectToSignIn() - redirect to signIn URL + redirectToSignUp() - redirect to signUp URL + redirectToSignOut() - redirect to signOut URL + redirectToAfterSignIn() - redirect to afterSignIn URL + redirectToAfterSignUp() - redirect to afterSignUp URL + redirectToAfterSignOut() - redirect to afterSignOut URL + redirectToHome() - redirect to home URL + redirectToAccountSettings() - redirect to accountSettings URL + redirectToForgotPassword() - redirect to forgotPassword URL + redirectToPasswordReset() - redirect to passwordReset URL + redirectToEmailVerification() - redirect to emailVerification URL + redirectToOnboarding() - redirect to onboarding URL + redirectToError() - redirect to error URL + redirectToMfa() - redirect to mfa URL + redirectToTeamInvitation() - redirect to teamInvitation URL + redirectToOAuthCallback() - redirect to oauthCallback URL + redirectToMagicLinkCallback() - redirect to magicLinkCallback URL + +Implementation: + +1. Get the target URL from the urls config +2. For signIn/signUp/onboarding (unless noRedirectBack=true): + - Check if current URL has after_auth_return_to query param + - If yes: preserve it in the target URL + - If no: set after_auth_return_to to current page URL +3. For afterSignIn/afterSignUp: + - Check current URL for after_auth_return_to query param + - If present: redirect to that URL instead of the default +4. Perform redirect based on redirectMethod config: + - "browser": window.location.assign() or .replace() + - "nextjs": Next.js redirect() function [JS-ONLY] + - "none": don't redirect (for headless/API use) + - Custom navigate function: call it with the URL All require browser or framework-specific redirect capability. Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index 130cbc88ba..2b6a57f9c8 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -289,6 +289,12 @@ Response: total: number } +EmailDeliveryInfo: + delivered: number - emails successfully delivered + bounced: number - emails that bounced (hard or soft) + complained: number - emails marked as spam by recipients + total: number - total emails sent + Does not error. @@ -327,16 +333,28 @@ Arguments: Returns: DataVaultStore -DataVaultStore has methods: +The Data Vault is a simple key-value store for storing sensitive data server-side. +Each store is isolated and identified by its ID. + +DataVaultStore: + id: string - the store ID + get(key: string): Promise GET /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Returns the value for the key, or null if not found. set(key: string, value: string): Promise PUT /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] Body: { value: string } + Sets or updates the value for the key. delete(key: string): Promise DELETE /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Deletes the key-value pair. No error if key doesn't exist. + + list(): Promise + GET /api/v1/data-vault/stores/{storeId}/items [server-only] + Returns all keys in the store. Does not error. diff --git a/sdks/spec/src/types/auth/oauth-connection.spec.md b/sdks/spec/src/types/auth/oauth-connection.spec.md index c7d55fc142..9ac79e9cf2 100644 --- a/sdks/spec/src/types/auth/oauth-connection.spec.md +++ b/sdks/spec/src/types/auth/oauth-connection.spec.md @@ -69,12 +69,12 @@ options: { allowConnectedAccounts?: bool, } -Returns: Result +Returns: void PATCH /api/v1/users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated] Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts -Errors (in Result): +Errors: OAuthProviderAccountIdAlreadyUsedForSignIn code: "oauth_provider_account_id_already_used_for_sign_in" message: "This OAuth account is already linked to another user for sign-in." @@ -111,12 +111,12 @@ options: { allowConnectedAccounts?: bool, } -Returns: Result +Returns: void PATCH /api/v1/users/{userId}/oauth-providers/{id} [server-only] Body: { account_id, email, allow_sign_in, allow_connected_accounts } -Errors (in Result): +Errors: OAuthProviderAccountIdAlreadyUsedForSignIn code: "oauth_provider_account_id_already_used_for_sign_in" message: "This OAuth account is already linked to another user for sign-in." diff --git a/sdks/spec/src/types/common/api-keys.spec.md b/sdks/spec/src/types/common/api-keys.spec.md new file mode 100644 index 0000000000..d0cc8f6195 --- /dev/null +++ b/sdks/spec/src/types/common/api-keys.spec.md @@ -0,0 +1,107 @@ +# ApiKey (Base) + +Base type for API keys. + + +## Properties + +id: string + Unique API key identifier. + +description: string + User-provided description of what this key is for. + +expiresAt: Date | null + When the key expires, or null if it never expires. + +createdAt: Date + When the key was created. + +isValid: bool + Whether the key is currently valid (not expired, not revoked). + + +## Methods + + +### revoke() + +DELETE /api/v1/api-keys/{id} [authenticated] + +Revokes the API key immediately. + +Does not error. + + +### update(options) + +options.description: string? +options.expiresAt: Date | null? + +PATCH /api/v1/api-keys/{id} { description, expires_at } [authenticated] + +Does not error. + + +--- + +# UserApiKey + +An API key owned by a user. + +Extends: ApiKey + + +## Additional Properties + +userId: string + The user who owns this key. + +teamId: string | null + If this key is scoped to a team, the team ID. + + +--- + +# UserApiKeyFirstView + +Returned only when creating a new API key. Contains the actual key value. + +Extends: UserApiKey + + +## Additional Properties + +apiKey: string + The actual API key value. Only returned once at creation time. + Store this securely - it cannot be retrieved again. + + +--- + +# TeamApiKey + +An API key owned by a team. + +Extends: ApiKey + + +## Additional Properties + +teamId: string + The team that owns this key. + + +--- + +# TeamApiKeyFirstView + +Returned only when creating a new team API key. + +Extends: TeamApiKey + + +## Additional Properties + +apiKey: string + The actual API key value. Only returned once at creation time. diff --git a/sdks/spec/src/types/common/sessions.spec.md b/sdks/spec/src/types/common/sessions.spec.md new file mode 100644 index 0000000000..cdf4920d73 --- /dev/null +++ b/sdks/spec/src/types/common/sessions.spec.md @@ -0,0 +1,55 @@ +# ActiveSession + +Represents an active login session for a user. + + +## Properties + +id: string + Unique session identifier. + +userId: string + The user this session belongs to. + +createdAt: Date + When the session was created. + +isImpersonation: bool + Whether this is an impersonation session (admin viewing as user). + +lastUsedAt: Date | null + When the session was last used for an API request. + +isCurrentSession: bool + Whether this is the session making the current request. + +geoInfo: GeoInfo | null + Geographic information about where the session was last used. + + +--- + +# GeoInfo + +Geographic information derived from IP address. + + +## Properties + +city: string | null + City name, if detected. + +region: string | null + Region/state name, if detected. + +country: string | null + Country code (ISO 3166-1 alpha-2), if detected. + +countryName: string | null + Full country name, if detected. + +latitude: number | null + Approximate latitude. + +longitude: number | null + Approximate longitude. diff --git a/sdks/spec/src/types/notifications/notification-category.spec.md b/sdks/spec/src/types/notifications/notification-category.spec.md new file mode 100644 index 0000000000..6c4a90de86 --- /dev/null +++ b/sdks/spec/src/types/notifications/notification-category.spec.md @@ -0,0 +1,42 @@ +# NotificationCategory + +A category of notifications that users can subscribe to or unsubscribe from. + + +## Properties + +id: string + Unique category identifier (e.g., "marketing", "product_updates", "security"). + +displayName: string + Human-readable name for the category. + +description: string | null + Description of what notifications this category includes. + +isSubscribedByDefault: bool + Whether users are subscribed to this category by default. + +isUserSubscribed: bool + Whether the current user is subscribed to this category. + + +## Methods + + +### subscribe() + +POST /api/v1/notification-preferences { category_id, subscribed: true } [authenticated] + +Subscribes the user to this notification category. + +Does not error. + + +### unsubscribe() + +POST /api/v1/notification-preferences { category_id, subscribed: false } [authenticated] + +Unsubscribes the user from this notification category. + +Does not error. diff --git a/sdks/spec/src/types/payments/customer.spec.md b/sdks/spec/src/types/payments/customer.spec.md index 64ff376697..3ceaedf600 100644 --- a/sdks/spec/src/types/payments/customer.spec.md +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -208,6 +208,31 @@ displayName: string prices: Price[] +--- + +# Price + +A price point for a product. + + +## Properties + +id: string + Unique price identifier. + +amount: number + Price amount in the smallest currency unit (e.g., cents for USD). + +currency: string + Three-letter currency code (e.g., "usd", "eur"). + +interval: "month" | "year" | null + Billing interval for subscriptions, or null for one-time purchases. + +intervalCount: number | null + Number of intervals between billings (e.g., 1 for monthly, 3 for quarterly). + + --- # ServerItem (server-only) diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md index 92ce9e69b8..06d1feea16 100644 --- a/sdks/spec/src/types/projects/project.spec.md +++ b/sdks/spec/src/types/projects/project.spec.md @@ -38,10 +38,16 @@ passkeyEnabled: bool oauthProviders: OAuthProviderConfig[] List of enabled OAuth providers. - Each has: id: string, type: "google" | "github" | "microsoft" | etc. + Each has: id: string clientTeamCreationEnabled: bool Whether clients can create teams. clientUserDeletionEnabled: bool Whether clients can delete their own accounts. + +allowUserApiKeys: bool + Whether users can create API keys. + +allowTeamApiKeys: bool + Whether teams can create API keys. diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md index f0caf67f3b..1718cc7094 100644 --- a/sdks/spec/src/types/teams/server-team.spec.md +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -41,9 +41,12 @@ Returns: ServerTeamUser[] GET /api/v1/teams/{teamId}/users [server-only] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts -ServerTeamUser extends ServerUser with: +ServerTeamUser: + Extends ServerUser with: teamProfile: ServerTeamMemberProfile +See types/teams/team-member-profile.spec.md for ServerTeamMemberProfile. + Does not error. diff --git a/sdks/spec/src/types/teams/team-member-profile.spec.md b/sdks/spec/src/types/teams/team-member-profile.spec.md new file mode 100644 index 0000000000..67ca9e1016 --- /dev/null +++ b/sdks/spec/src/types/teams/team-member-profile.spec.md @@ -0,0 +1,66 @@ +# TeamMemberProfile + +A user's profile within a specific team. Teams can have per-user display names +and profile images that differ from the user's global profile. + + +## Properties + +displayName: string | null + The user's display name within this team. + +profileImageUrl: string | null + The user's profile image URL within this team. + + +--- + +# EditableTeamMemberProfile + +The current user's editable profile within a team. + +Extends: TeamMemberProfile + + +## Methods + + +### update(options) + +options.displayName: string | null? +options.profileImageUrl: string | null? + +PATCH /api/v1/teams/{teamId}/users/me/profile { display_name, profile_image_url } [authenticated] + +Updates the current user's profile within the team. + +Does not error. + + +--- + +# ServerTeamMemberProfile + +Server-side team member profile with additional management capabilities. + +Extends: TeamMemberProfile + + +## Additional Properties + +userId: string + The user ID this profile belongs to. + + +## Methods + + +### update(options) + +options.displayName: string | null? +options.profileImageUrl: string | null? + +PATCH /api/v1/teams/{teamId}/users/{userId}/profile [server-only] +Body: { display_name, profile_image_url } + +Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 9cb856bd96..68284955c6 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -66,13 +66,11 @@ Returns: TeamUser[] GET /api/v1/teams/{teamId}/users [authenticated] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts -TeamUser has: - id: string - teamProfile: TeamMemberProfile +TeamUser: + id: string - user ID + teamProfile: TeamMemberProfile - user's profile within this team -TeamMemberProfile has: - displayName: string | null - profileImageUrl: string | null +See types/teams/team-member-profile.spec.md for TeamMemberProfile. Does not error. @@ -83,11 +81,14 @@ Returns: TeamInvitation[] GET /api/v1/teams/{teamId}/invitations [authenticated] -TeamInvitation has: - id: string - recipientEmail: string | null - expiresAt: Date +TeamInvitation: + id: string - invitation ID + recipientEmail: string | null - email the invitation was sent to + expiresAt: Date - when the invitation expires + revoke(): Promise + DELETE /api/v1/teams/{teamId}/invitations/{id} [authenticated] + Revokes the invitation so it can no longer be accepted. Does not error. @@ -102,8 +103,8 @@ Returns: TeamApiKeyFirstView POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] -TeamApiKeyFirstView extends TeamApiKey with: - apiKey: string - the actual key value (only shown once) +See types/common/api-keys.spec.md for TeamApiKeyFirstView. +The apiKey property is only returned once at creation time. Does not error. @@ -114,11 +115,7 @@ Returns: TeamApiKey[] GET /api/v1/teams/{teamId}/api-keys [authenticated] -TeamApiKey has: - id: string - description: string - expiresAt: Date | null - createdAt: Date +See types/common/api-keys.spec.md for TeamApiKey. Does not error. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index d158d69de4..86ca49a8d9 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -175,10 +175,7 @@ Returns: EditableTeamMemberProfile GET /api/v1/teams/{teamId}/users/me/profile [authenticated] -EditableTeamMemberProfile has: - displayName: string | null - profileImageUrl: string | null - update(options): Promise +See types/teams/team-member-profile.spec.md for EditableTeamMemberProfile. Does not error. @@ -346,14 +343,7 @@ Returns: ActiveSession[] GET /api/v1/users/me/sessions [authenticated] -ActiveSession has: - id: string - userId: string - createdAt: Date - isImpersonation: bool - lastUsedAt: Date | null - isCurrentSession: bool - geoInfo: GeoInfo? +See types/common/sessions.spec.md for ActiveSession and GeoInfo. Does not error. @@ -402,6 +392,8 @@ Returns: UserApiKey[] GET /api/v1/users/me/api-keys [authenticated] +See types/common/api-keys.spec.md for UserApiKey. + Does not error. @@ -416,8 +408,8 @@ Returns: UserApiKeyFirstView POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] -UserApiKeyFirstView extends UserApiKey with: - apiKey: string - the actual key value (only shown once) +See types/common/api-keys.spec.md for UserApiKeyFirstView. +The apiKey property is only returned once at creation time. Does not error. @@ -431,22 +423,69 @@ Returns: NotificationCategory[] GET /api/v1/notification-categories [authenticated] +See types/notifications/notification-category.spec.md for NotificationCategory. + Does not error. -## Auth Methods (from StackClientApp) +## Auth Methods + +These methods are available on the CurrentUser object for convenience. +They operate on the user's current session. -signOut(options?) - Same as StackClientApp.signOut() -getAccessToken() - Same as StackClientApp.getAccessToken() +### signOut(options?) + +options.redirectUrl: string? - where to redirect after sign out + +Signs out the current user by invalidating their session. + +Implementation: +1. DELETE /api/v1/auth/sessions/current [authenticated] + (Ignore errors - session may already be invalid) +2. Clear stored tokens +3. Redirect to redirectUrl or afterSignOut URL + +Does not error. + + +### getAccessToken() + +Returns: string | null + +Returns the current access token, refreshing if needed. +Returns null if not authenticated. + +Does not error. -getRefreshToken() - Same as StackClientApp.getRefreshToken() -getAuthHeaders() - Same as StackClientApp.getAuthHeaders() +### getRefreshToken() + +Returns: string | null + +Returns the current refresh token. +Returns null if not authenticated. + +Does not error. + + +### getAuthHeaders() + +Returns: { "x-stack-auth": string } + +Returns headers for cross-origin authenticated requests. +The value is JSON: { "accessToken": "", "refreshToken": "" } + +Does not error. + + +### getAuthJson() + +Returns: { accessToken: string | null, refreshToken: string | null } + +Returns the current tokens as an object. + +Does not error. ## Deprecated Methods From 66b066db6ec3663b5b16fa0a508774a557452a70 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 13:14:13 -0800 Subject: [PATCH 04/17] Swift SDK --- pnpm-lock.yaml | 2 + sdks/implementations/swift/.gitignore | 13 + sdks/implementations/swift/Package.swift | 32 + sdks/implementations/swift/README.md | 160 +++++ .../swift/Sources/StackAuth/APIClient.swift | 257 +++++++ .../swift/Sources/StackAuth/Errors.swift | 178 +++++ .../Sources/StackAuth/Models/ApiKey.swift | 99 +++ .../StackAuth/Models/ContactChannel.swift | 70 ++ .../StackAuth/Models/CurrentUser.swift | 357 ++++++++++ .../Sources/StackAuth/Models/Permission.swift | 11 + .../Sources/StackAuth/Models/Project.swift | 87 +++ .../Sources/StackAuth/Models/ServerTeam.swift | 176 +++++ .../Sources/StackAuth/Models/ServerUser.swift | 262 +++++++ .../Sources/StackAuth/Models/Session.swift | 55 ++ .../swift/Sources/StackAuth/Models/Team.swift | 210 ++++++ .../swift/Sources/StackAuth/Models/User.swift | 81 +++ .../Sources/StackAuth/StackClientApp.swift | 665 ++++++++++++++++++ .../Sources/StackAuth/StackServerApp.swift | 266 +++++++ .../swift/Sources/StackAuth/TokenStore.swift | 190 +++++ .../StackAuthTests/AuthenticationTests.swift | 284 ++++++++ .../StackAuthTests/ContactChannelTests.swift | 182 +++++ .../Tests/StackAuthTests/ErrorTests.swift | 248 +++++++ .../Tests/StackAuthTests/OAuthTests.swift | 130 ++++ .../Tests/StackAuthTests/TeamTests.swift | 457 ++++++++++++ .../Tests/StackAuthTests/TestConfig.swift | 79 +++ .../Tests/StackAuthTests/TokenTests.swift | 239 +++++++ .../StackAuthTests/UserManagementTests.swift | 415 +++++++++++ sdks/spec/src/_utilities.spec.md | 16 +- sdks/spec/src/apps/client-app.spec.md | 38 +- sdks/spec/src/apps/server-app.spec.md | 9 +- sdks/spec/src/types/teams/server-team.spec.md | 9 +- sdks/spec/src/types/teams/team.spec.md | 7 +- .../spec/src/types/users/current-user.spec.md | 6 +- sdks/spec/src/types/users/server-user.spec.md | 16 +- 34 files changed, 5287 insertions(+), 19 deletions(-) create mode 100644 sdks/implementations/swift/.gitignore create mode 100644 sdks/implementations/swift/Package.swift create mode 100644 sdks/implementations/swift/README.md create mode 100644 sdks/implementations/swift/Sources/StackAuth/APIClient.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Errors.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Project.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Session.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Team.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/User.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/TokenStore.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b533bc9fb3..7f55d50850 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2198,6 +2198,8 @@ importers: specifier: ^8.0.2 version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.0) + sdks/spec: {} + packages: '@ai-sdk/google@1.2.22': diff --git a/sdks/implementations/swift/.gitignore b/sdks/implementations/swift/.gitignore new file mode 100644 index 0000000000..6a14c30669 --- /dev/null +++ b/sdks/implementations/swift/.gitignore @@ -0,0 +1,13 @@ +xcuserdata/ +*.hmap +*.ipa +*.dSYM.zip +*.dSYM +timeline.xctimeline +playground.xcworkspace +.build/ +Carthage/Build/ +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output diff --git a/sdks/implementations/swift/Package.swift b/sdks/implementations/swift/Package.swift new file mode 100644 index 0000000000..9ba21f29d0 --- /dev/null +++ b/sdks/implementations/swift/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StackAuth", + platforms: [ + .iOS(.v15), + .macOS(.v12), + .watchOS(.v8), + .tvOS(.v15), + .visionOS(.v1) + ], + products: [ + .library( + name: "StackAuth", + targets: ["StackAuth"] + ), + ], + dependencies: [], + targets: [ + .target( + name: "StackAuth", + dependencies: [], + path: "Sources/StackAuth" + ), + .testTarget( + name: "StackAuthTests", + dependencies: ["StackAuth"], + path: "Tests/StackAuthTests" + ), + ] +) diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md new file mode 100644 index 0000000000..0f49aecfb9 --- /dev/null +++ b/sdks/implementations/swift/README.md @@ -0,0 +1,160 @@ +# Stack Auth Swift SDK + +Swift SDK for Stack Auth. Supports iOS, macOS, watchOS, tvOS, and visionOS. + +## Requirements + +- Swift 5.9+ +- iOS 15+ / macOS 12+ / watchOS 8+ / tvOS 15+ / visionOS 1+ + +## Installation + +Add to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/stack-auth/stack-swift", from: "1.0.0") +] +``` + +## Quick Start + +```swift +import StackAuth + +let stack = StackClientApp( + projectId: "your-project-id", + publishableClientKey: "your-key" +) + +// Sign in with email/password +try await stack.signInWithCredential(email: "user@example.com", password: "password") + +// Get current user +if let user = try await stack.getUser() { + print("Signed in as \(user.displayName ?? "Unknown")") +} + +// Sign out +try await user.signOut() +``` + +## Design Decisions + +### Error Handling + +All functions that can fail use Swift's native `throws`. Errors conform to `StackAuthError`: + +```swift +do { + try await stack.signInWithCredential(email: email, password: password) +} catch let error as StackAuthError { + switch error.code { + case "email_password_mismatch": + print("Wrong password") + default: + print(error.message) + } +} +``` + +### Token Storage + +- **Default**: Keychain (secure, persists across app launches) +- **Option**: Memory (for testing or ephemeral sessions) +- **Option**: Custom `TokenStore` protocol implementation + +```swift +// Memory storage (for testing) +let stack = StackClientApp( + projectId: "...", + publishableClientKey: "...", + tokenStore: .memory +) + +// Custom storage +let stack = StackClientApp( + projectId: "...", + publishableClientKey: "...", + tokenStore: .custom(MyTokenStore()) +) +``` + +### OAuth Flows + +Two approaches for OAuth authentication: + +**1. Integrated (recommended)** - Uses `ASWebAuthenticationSession`: + +```swift +// Opens auth session, handles callback automatically +try await stack.signInWithOAuth(provider: "google") +``` + +**2. Manual URL handling** - For custom implementations: + +```swift +// Get the OAuth URL +let oauth = try await stack.getOAuthUrl(provider: "google") + +// Open oauth.url in your own browser/webview +// Store oauth.state and oauth.codeVerifier + +// When callback received: +try await stack.callOAuthCallback( + url: callbackUrl, + codeVerifier: oauth.codeVerifier +) +``` + +### Async/Await + +All async operations use Swift's native concurrency: + +```swift +Task { + let user = try await stack.getUser() + let teams = try await user?.listTeams() +} +``` + +## Key Differences from JavaScript SDK + +| Aspect | JavaScript | Swift | +|--------|-----------|-------| +| Token Storage | Cookies | Keychain | +| OAuth | Browser redirect | ASWebAuthenticationSession | +| Redirect methods | Available | Not available (browser-only) | +| React hooks | `useUser()` etc. | Not applicable | +| Error handling | Result types | `throws` | + +### Not Available in Swift + +The following are browser-only and not exposed: + +- `redirectToSignIn()`, `redirectToSignUp()`, etc. +- Cookie-based token storage +- `redirectMethod` constructor option + +## Testing + +Tests use Swift Testing framework against a running backend. + +### Running Tests + +1. Start the development server: + ```bash + pnpm dev + ``` + +2. Run tests: + ```bash + cd sdks/implementations/swift + swift test + ``` + +The tests connect to `http://localhost:8102` (or `${NEXT_PUBLIC_STACK_PORT_PREFIX}02`). + +## API Reference + +See the [SDK Specification](../../spec/README.md) for complete API documentation. diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift new file mode 100644 index 0000000000..e7aa3a3895 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -0,0 +1,257 @@ +import Foundation + +/// Internal API client for making HTTP requests to Stack Auth +actor APIClient { + let baseUrl: String + let projectId: String + let publishableClientKey: String + let secretServerKey: String? + private let tokenStore: any TokenStoreProtocol + private var isRefreshing = false + private var refreshWaiters: [CheckedContinuation] = [] + + private static let sdkVersion = "1.0.0" + + init( + baseUrl: String, + projectId: String, + publishableClientKey: String, + secretServerKey: String? = nil, + tokenStore: any TokenStoreProtocol + ) { + self.baseUrl = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl + self.projectId = projectId + self.publishableClientKey = publishableClientKey + self.secretServerKey = secretServerKey + self.tokenStore = tokenStore + } + + // MARK: - Request Methods + + func sendRequest( + path: String, + method: String = "GET", + body: [String: Any]? = nil, + authenticated: Bool = false, + serverOnly: Bool = false + ) async throws -> (Data, HTTPURLResponse) { + let url = URL(string: "\(baseUrl)/api/v1\(path)")! + var request = URLRequest(url: url) + request.httpMethod = method + request.cachePolicy = .reloadIgnoringLocalCacheData + + // Required headers + request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key") + request.setValue("swift@\(Self.sdkVersion)", forHTTPHeaderField: "x-stack-client-version") + request.setValue(serverOnly ? "server" : "client", forHTTPHeaderField: "x-stack-access-type") + request.setValue("true", forHTTPHeaderField: "x-stack-override-error-status") + request.setValue(UUID().uuidString, forHTTPHeaderField: "x-stack-random-nonce") + + // Server key if required + if serverOnly { + guard let serverKey = secretServerKey else { + throw StackAuthError(code: "missing_server_key", message: "Server key required for this operation") + } + request.setValue(serverKey, forHTTPHeaderField: "x-stack-secret-server-key") + } + + // Auth headers + if authenticated { + if let accessToken = await tokenStore.getAccessToken() { + request.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token") + } + if let refreshToken = await tokenStore.getRefreshToken() { + request.setValue(refreshToken, forHTTPHeaderField: "x-stack-refresh-token") + } + } + + // Body - always include for mutating methods + if let body = body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } else if method == "POST" || method == "PATCH" || method == "PUT" { + // POST/PATCH/PUT requests need a body even if empty + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = "{}".data(using: .utf8) + } + + // Send request with retry logic + return try await sendWithRetry(request: request, authenticated: authenticated) + } + + private func sendWithRetry( + request: URLRequest, + authenticated: Bool, + attempt: Int = 0 + ) async throws -> (Data, HTTPURLResponse) { + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw StackAuthError(code: "invalid_response", message: "Invalid HTTP response") + } + + // Check for actual status code in header + let actualStatus: Int + if let statusHeader = httpResponse.value(forHTTPHeaderField: "x-stack-actual-status"), + let status = Int(statusHeader) { + actualStatus = status + } else { + actualStatus = httpResponse.statusCode + } + + // Handle 401 with token refresh + if actualStatus == 401 && authenticated { + // Check if it's an invalid access token error + if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error"), + errorCode == "invalid_access_token" { + // Try to refresh token + let refreshed = try await refreshTokenIfNeeded() + if refreshed { + // Retry with new token + var newRequest = request + if let accessToken = await tokenStore.getAccessToken() { + newRequest.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token") + } + return try await sendWithRetry(request: newRequest, authenticated: authenticated, attempt: 0) + } + } + } + + // Handle rate limiting + if actualStatus == 429 { + if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(retryAfter) { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } + } + + // Check for known error + if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error") { + let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let message = errorData?["message"] as? String ?? "Unknown error" + let details = errorData?["details"] as? [String: Any] + throw StackAuthError.from(code: errorCode, message: message, details: details) + } + + // Success + if actualStatus >= 200 && actualStatus < 300 { + return (data, httpResponse) + } + + // Other error + throw StackAuthError(code: "http_error", message: "HTTP \(actualStatus)") + + } catch let error as URLError { + // Network error - retry for idempotent requests + let idempotent = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"].contains(request.httpMethod ?? "") + if idempotent && attempt < 5 { + let delay = pow(2.0, Double(attempt)) * 1.0 // Exponential backoff + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } + throw StackAuthError(code: "network_error", message: error.localizedDescription) + } + } + + // MARK: - Token Refresh + + private func refreshTokenIfNeeded() async throws -> Bool { + // Wait if already refreshing + if isRefreshing { + await withCheckedContinuation { continuation in + refreshWaiters.append(continuation) + } + return await tokenStore.getAccessToken() != nil + } + + guard let refreshToken = await tokenStore.getRefreshToken() else { + return false + } + + isRefreshing = true + defer { + isRefreshing = false + for waiter in refreshWaiters { + waiter.resume() + } + refreshWaiters.removeAll() + } + + // Build token refresh request + let url = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key") + + let body = [ + "grant_type=refresh_token", + "refresh_token=\(refreshToken.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? refreshToken)", + "client_id=\(projectId)", + "client_secret=\(publishableClientKey)" + ].joined(separator: "&") + + request.httpBody = body.data(using: .utf8) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + // Refresh failed - clear tokens + await tokenStore.clearTokens() + return false + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let newAccessToken = json["access_token"] as? String else { + await tokenStore.clearTokens() + return false + } + + let newRefreshToken = json["refresh_token"] as? String + await tokenStore.setTokens( + accessToken: newAccessToken, + refreshToken: newRefreshToken ?? refreshToken + ) + + return true + } catch { + await tokenStore.clearTokens() + return false + } + } + + // MARK: - Token Management + + func setTokens(accessToken: String?, refreshToken: String?) async { + await tokenStore.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + func clearTokens() async { + await tokenStore.clearTokens() + } + + func getAccessToken() async -> String? { + return await tokenStore.getAccessToken() + } + + func getRefreshToken() async -> String? { + return await tokenStore.getRefreshToken() + } +} + +// MARK: - JSON Parsing Helpers + +extension APIClient { + func parseJSON(_ data: Data) throws -> T { + guard let json = try? JSONSerialization.jsonObject(with: data) as? T else { + throw StackAuthError(code: "parse_error", message: "Failed to parse response") + } + return json + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Errors.swift b/sdks/implementations/swift/Sources/StackAuth/Errors.swift new file mode 100644 index 0000000000..9b22f7452b --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Errors.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Base protocol for all Stack Auth errors +public protocol StackAuthErrorProtocol: Error, CustomStringConvertible { + var code: String { get } + var message: String { get } + var details: [String: Any]? { get } +} + +/// Standard Stack Auth API error +public struct StackAuthError: StackAuthErrorProtocol { + public let code: String + public let message: String + public let details: [String: Any]? + + public var description: String { + "StackAuthError(\(code)): \(message)" + } + + public init(code: String, message: String, details: [String: Any]? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +// MARK: - Specific Error Types + +public struct EmailPasswordMismatchError: StackAuthErrorProtocol { + public let code = "EMAIL_PASSWORD_MISMATCH" + public let message = "The email and password combination is incorrect." + public let details: [String: Any]? = nil + public var description: String { "EmailPasswordMismatchError: \(message)" } +} + +public struct UserWithEmailAlreadyExistsError: StackAuthErrorProtocol { + public let code = "USER_EMAIL_ALREADY_EXISTS" + public let message = "A user with this email address already exists." + public let details: [String: Any]? = nil + public var description: String { "UserWithEmailAlreadyExistsError: \(message)" } +} + +public struct PasswordRequirementsNotMetError: StackAuthErrorProtocol { + public let code = "PASSWORD_REQUIREMENTS_NOT_MET" + public let message = "The password does not meet the project's requirements." + public let details: [String: Any]? = nil + public var description: String { "PasswordRequirementsNotMetError: \(message)" } +} + +public struct UserNotFoundError: StackAuthErrorProtocol { + public let code = "USER_NOT_FOUND" + public let message = "No user with this email address was found." + public let details: [String: Any]? = nil + public var description: String { "UserNotFoundError: \(message)" } +} + +public struct VerificationCodeError: StackAuthErrorProtocol { + public let code = "VERIFICATION_CODE_ERROR" + public let message = "The verification code is invalid or expired." + public let details: [String: Any]? = nil + public var description: String { "VerificationCodeError: \(message)" } +} + +public struct InvalidTotpCodeError: StackAuthErrorProtocol { + public let code = "INVALID_TOTP_CODE" + public let message = "The MFA code is incorrect." + public let details: [String: Any]? = nil + public var description: String { "InvalidTotpCodeError: \(message)" } +} + +public struct RedirectUrlNotWhitelistedError: StackAuthErrorProtocol { + public let code = "REDIRECT_URL_NOT_WHITELISTED" + public let message = "The callback URL is not in the project's trusted domains list." + public let details: [String: Any]? = nil + public var description: String { "RedirectUrlNotWhitelistedError: \(message)" } +} + +public struct PasskeyAuthenticationFailedError: StackAuthErrorProtocol { + public let code = "PASSKEY_AUTHENTICATION_FAILED" + public let message = "Passkey authentication failed. Please try again." + public let details: [String: Any]? = nil + public var description: String { "PasskeyAuthenticationFailedError: \(message)" } +} + +public struct PasskeyWebAuthnError: StackAuthErrorProtocol { + public let code = "PASSKEY_WEBAUTHN_ERROR" + public let message: String + public let details: [String: Any]? = nil + public var description: String { "PasskeyWebAuthnError: \(message)" } + + public init(errorName: String) { + self.message = "WebAuthn error: \(errorName)." + } +} + +public struct MultiFactorAuthenticationRequiredError: StackAuthErrorProtocol { + public let code = "MULTI_FACTOR_AUTHENTICATION_REQUIRED" + public let message = "Multi-factor authentication is required." + public let attemptCode: String + public var details: [String: Any]? { ["attempt_code": attemptCode] } + public var description: String { "MultiFactorAuthenticationRequiredError: \(message)" } + + public init(attemptCode: String) { + self.attemptCode = attemptCode + } +} + +public struct UserNotSignedInError: StackAuthErrorProtocol { + public let code = "USER_NOT_SIGNED_IN" + public let message = "User is not signed in." + public let details: [String: Any]? = nil + public var description: String { "UserNotSignedInError: \(message)" } +} + +public struct OAuthError: StackAuthErrorProtocol { + public let code: String + public let message: String + public let details: [String: Any]? + public var description: String { "OAuthError(\(code)): \(message)" } + + public init(code: String, message: String, details: [String: Any]? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +public struct PasswordConfirmationMismatchError: StackAuthErrorProtocol { + public let code = "PASSWORD_CONFIRMATION_MISMATCH" + public let message = "The current password is incorrect." + public let details: [String: Any]? = nil + public var description: String { "PasswordConfirmationMismatchError: \(message)" } +} + +public struct OAuthProviderAccountIdAlreadyUsedError: StackAuthErrorProtocol { + public let code = "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN" + public let message = "This OAuth account is already linked to another user for sign-in." + public let details: [String: Any]? = nil + public var description: String { "OAuthProviderAccountIdAlreadyUsedError: \(message)" } +} + +// MARK: - Error Parsing + +extension StackAuthError { + /// Parse error from API response + /// Error codes from the API are UPPERCASE_WITH_UNDERSCORES + static func from(code: String, message: String, details: [String: Any]? = nil) -> any StackAuthErrorProtocol { + switch code { + case "EMAIL_PASSWORD_MISMATCH": + return EmailPasswordMismatchError() + case "USER_EMAIL_ALREADY_EXISTS": + return UserWithEmailAlreadyExistsError() + case "PASSWORD_REQUIREMENTS_NOT_MET": + return PasswordRequirementsNotMetError() + case "USER_NOT_FOUND": + return UserNotFoundError() + case "VERIFICATION_CODE_ERROR": + return VerificationCodeError() + case "INVALID_TOTP_CODE": + return InvalidTotpCodeError() + case "REDIRECT_URL_NOT_WHITELISTED": + return RedirectUrlNotWhitelistedError() + case "PASSKEY_AUTHENTICATION_FAILED": + return PasskeyAuthenticationFailedError() + case "MULTI_FACTOR_AUTHENTICATION_REQUIRED": + if let attemptCode = details?["attempt_code"] as? String { + return MultiFactorAuthenticationRequiredError(attemptCode: attemptCode) + } + return StackAuthError(code: code, message: message, details: details) + case "PASSWORD_CONFIRMATION_MISMATCH": + return PasswordConfirmationMismatchError() + case "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN": + return OAuthProviderAccountIdAlreadyUsedError() + default: + return StackAuthError(code: code, message: message, details: details) + } + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift new file mode 100644 index 0000000000..dd3fa6fa06 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Base API key properties +public struct ApiKeyBase: Sendable { + public let id: String + public let description: String + public let expiresAt: Date? + public let createdAt: Date + public let isValid: Bool + + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.description = json["description"] as? String ?? "" + + if let expiresMillis = json["expires_at_millis"] as? Int64 ?? json["expires_at"] as? Int64 { + self.expiresAt = Date(timeIntervalSince1970: Double(expiresMillis) / 1000.0) + } else { + self.expiresAt = nil + } + + let createdMillis = json["created_at_millis"] as? Int64 ?? json["created_at"] as? Int64 ?? 0 + self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + + self.isValid = json["is_valid"] as? Bool ?? true + } +} + +/// User API key +public struct UserApiKey: Sendable { + public let base: ApiKeyBase + public let userId: String + public let teamId: String? + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + + init(from json: [String: Any]) { + self.base = ApiKeyBase(from: json) + self.userId = json["user_id"] as? String ?? "" + self.teamId = json["team_id"] as? String + } +} + +/// User API key with the key value (only returned on creation) +public struct UserApiKeyFirstView: Sendable { + public let base: UserApiKey + public let apiKey: String + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + public var userId: String { base.userId } + public var teamId: String? { base.teamId } + + init(from json: [String: Any]) { + self.base = UserApiKey(from: json) + self.apiKey = json["api_key"] as? String ?? "" + } +} + +/// Team API key +public struct TeamApiKey: Sendable { + public let base: ApiKeyBase + public let teamId: String + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + + init(from json: [String: Any]) { + self.base = ApiKeyBase(from: json) + self.teamId = json["team_id"] as? String ?? "" + } +} + +/// Team API key with the key value (only returned on creation) +public struct TeamApiKeyFirstView: Sendable { + public let base: TeamApiKey + public let apiKey: String + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + public var teamId: String { base.teamId } + + init(from json: [String: Any]) { + self.base = TeamApiKey(from: json) + self.apiKey = json["api_key"] as? String ?? "" + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift new file mode 100644 index 0000000000..a29b475158 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift @@ -0,0 +1,70 @@ +import Foundation + +/// A contact channel (email) associated with a user +public actor ContactChannel { + private let client: APIClient + + public nonisolated let id: String + public private(set) var value: String + public let type: String + public private(set) var isPrimary: Bool + public private(set) var isVerified: Bool + public private(set) var usedForAuth: Bool + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.value = json["value"] as? String ?? "" + self.type = json["type"] as? String ?? "email" + self.isPrimary = json["is_primary"] as? Bool ?? false + self.isVerified = json["is_verified"] as? Bool ?? false + self.usedForAuth = json["used_for_auth"] as? Bool ?? false + } + + public func update( + value: String? = nil, + usedForAuth: Bool? = nil, + isPrimary: Bool? = nil + ) async throws { + var body: [String: Any] = [:] + if let value = value { body["value"] = value } + if let usedForAuth = usedForAuth { body["used_for_auth"] = usedForAuth } + if let isPrimary = isPrimary { body["is_primary"] = isPrimary } + + let (data, _) = try await client.sendRequest( + path: "/contact-channels/\(id)", + method: "PATCH", + body: body, + authenticated: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.value = json["value"] as? String ?? self.value + self.isPrimary = json["is_primary"] as? Bool ?? self.isPrimary + self.isVerified = json["is_verified"] as? Bool ?? self.isVerified + self.usedForAuth = json["used_for_auth"] as? Bool ?? self.usedForAuth + } + } + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/contact-channels/\(id)", + method: "DELETE", + authenticated: true + ) + } + + public func sendVerificationEmail(callbackUrl: String? = nil) async throws { + var body: [String: Any] = [:] + if let callbackUrl = callbackUrl { + body["callback_url"] = callbackUrl + } + + _ = try await client.sendRequest( + path: "/contact-channels/\(id)/send-verification-email", + method: "POST", + body: body.isEmpty ? nil : body, + authenticated: true + ) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift new file mode 100644 index 0000000000..92d0915f0a --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -0,0 +1,357 @@ +import Foundation + +/// The authenticated current user with methods to modify their data +public actor CurrentUser { + private let client: APIClient + private var userData: User + public let selectedTeam: Team? + + // User properties (delegated to userData) + public var id: String { userData.id } + public var displayName: String? { userData.displayName } + public var primaryEmail: String? { userData.primaryEmail } + public var primaryEmailVerified: Bool { userData.primaryEmailVerified } + public var profileImageUrl: String? { userData.profileImageUrl } + public var signedUpAt: Date { userData.signedUpAt } + public var clientMetadata: [String: Any] { userData.clientMetadata } + public var clientReadOnlyMetadata: [String: Any] { userData.clientReadOnlyMetadata } + public var hasPassword: Bool { userData.hasPassword } + public var emailAuthEnabled: Bool { userData.emailAuthEnabled } + public var otpAuthEnabled: Bool { userData.otpAuthEnabled } + public var passkeyAuthEnabled: Bool { userData.passkeyAuthEnabled } + public var isMultiFactorRequired: Bool { userData.isMultiFactorRequired } + public var isAnonymous: Bool { userData.isAnonymous } + public var isRestricted: Bool { userData.isRestricted } + public var restrictedReason: User.RestrictedReason? { userData.restrictedReason } + public var oauthProviders: [User.OAuthProviderInfo] { userData.oauthProviders } + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.userData = User(from: json) + + if let teamJson = json["selected_team"] as? [String: Any] { + self.selectedTeam = Team(client: client, json: teamJson) + } else { + self.selectedTeam = nil + } + } + + // MARK: - Update Methods + + public func update( + displayName: String? = nil, + clientMetadata: [String: Any]? = nil, + selectedTeamId: String? = nil, + profileImageUrl: String? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata } + if let selectedTeamId = selectedTeamId { body["selected_team_id"] = selectedTeamId } + if let profileImageUrl = profileImageUrl { body["profile_image_url"] = profileImageUrl } + + let (data, _) = try await client.sendRequest( + path: "/users/me", + method: "PATCH", + body: body, + authenticated: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.userData = User(from: json) + } + } + + public func setDisplayName(_ displayName: String?) async throws { + try await update(displayName: displayName) + } + + public func setClientMetadata(_ metadata: [String: Any]) async throws { + try await update(clientMetadata: metadata) + } + + public func setSelectedTeam(_ team: Team?) async throws { + try await update(selectedTeamId: team?.id) + } + + public func setSelectedTeam(id teamId: String?) async throws { + try await update(selectedTeamId: teamId) + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/users/me", + method: "DELETE", + authenticated: true + ) + await client.clearTokens() + } + + // MARK: - Password Methods + + public func updatePassword(oldPassword: String, newPassword: String) async throws { + _ = try await client.sendRequest( + path: "/auth/password/update", + method: "POST", + body: [ + "old_password": oldPassword, + "new_password": newPassword + ], + authenticated: true + ) + } + + public func setPassword(_ password: String) async throws { + _ = try await client.sendRequest( + path: "/auth/password/set", + method: "POST", + body: ["password": password], + authenticated: true + ) + } + + // MARK: - Team Methods + + public func listTeams() async throws -> [Team] { + let (data, _) = try await client.sendRequest( + path: "/teams?user_id=me", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { Team(client: client, json: $0) } + } + + public func getTeam(id teamId: String) async throws -> Team? { + let teams = try await listTeams() + return teams.first { $0.id == teamId } + } + + public func createTeam(displayName: String, profileImageUrl: String? = nil) async throws -> Team { + var body: [String: Any] = [ + "display_name": displayName, + "creator_user_id": "me" + ] + if let url = profileImageUrl { + body["profile_image_url"] = url + } + + let (data, _) = try await client.sendRequest( + path: "/teams", + method: "POST", + body: body, + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse team response") + } + + let team = Team(client: client, json: json) + try await setSelectedTeam(team) + return team + } + + public func leaveTeam(_ team: Team) async throws { + _ = try await client.sendRequest( + path: "/teams/\(team.id)/users/me", + method: "DELETE", + authenticated: true + ) + } + + // MARK: - Contact Channel Methods + + public func listContactChannels() async throws -> [ContactChannel] { + let (data, _) = try await client.sendRequest( + path: "/contact-channels?user_id=me", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ContactChannel(client: client, json: $0) } + } + + public func createContactChannel( + type: String = "email", + value: String, + usedForAuth: Bool, + isPrimary: Bool = false + ) async throws -> ContactChannel { + let (data, _) = try await client.sendRequest( + path: "/contact-channels", + method: "POST", + body: [ + "type": type, + "value": value, + "used_for_auth": usedForAuth, + "is_primary": isPrimary, + "user_id": "me" + ], + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse contact channel response") + } + + return ContactChannel(client: client, json: json) + } + + // MARK: - Session Methods + + public func getActiveSessions() async throws -> [ActiveSession] { + let (data, _) = try await client.sendRequest( + path: "/users/me/sessions", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ActiveSession(from: $0) } + } + + public func revokeSession(id sessionId: String) async throws { + _ = try await client.sendRequest( + path: "/users/me/sessions/\(sessionId)", + method: "DELETE", + authenticated: true + ) + } + + // MARK: - Auth Methods + + public func signOut() async throws { + // Ignore errors - session may already be invalid + _ = try? await client.sendRequest( + path: "/auth/sessions/current", + method: "DELETE", + authenticated: true + ) + await client.clearTokens() + } + + public func getAccessToken() async -> String? { + return await client.getAccessToken() + } + + public func getRefreshToken() async -> String? { + return await client.getRefreshToken() + } + + public func getAuthHeaders() async -> [String: String] { + let accessToken = await client.getAccessToken() + let refreshToken = await client.getRefreshToken() + + let json: [String: Any?] = [ + "accessToken": accessToken, + "refreshToken": refreshToken + ] + + if let data = try? JSONSerialization.data(withJSONObject: json), + let string = String(data: data, encoding: .utf8) { + return ["x-stack-auth": string] + } + + return ["x-stack-auth": "{}"] + } + + // MARK: - Permission Methods + + public func hasPermission(id permissionId: String, team: Team? = nil) async throws -> Bool { + let permission = try await getPermission(id: permissionId, team: team) + return permission != nil + } + + public func getPermission(id permissionId: String, team: Team? = nil) async throws -> TeamPermission? { + let permissions = try await listPermissions(team: team) + return permissions.first { $0.id == permissionId } + } + + public func listPermissions(team: Team? = nil, recursive: Bool = true) async throws -> [TeamPermission] { + var path = "/users/me/permissions" + var query: [String] = [] + + if let team = team { + query.append("team_id=\(team.id)") + } + query.append("recursive=\(recursive)") + + if !query.isEmpty { + path += "?" + query.joined(separator: "&") + } + + let (data, _) = try await client.sendRequest( + path: path, + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamPermission(id: $0["id"] as? String ?? "") } + } + + // MARK: - API Key Methods + + public func listApiKeys() async throws -> [UserApiKey] { + let (data, _) = try await client.sendRequest( + path: "/users/me/api-keys", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { UserApiKey(from: $0) } + } + + public func createApiKey( + description: String, + expiresAt: Date? = nil, + scope: String? = nil, + teamId: String? = nil + ) async throws -> UserApiKeyFirstView { + var body: [String: Any] = ["description": description] + if let expiresAt = expiresAt { + body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + } + if let scope = scope { body["scope"] = scope } + if let teamId = teamId { body["team_id"] = teamId } + + let (data, _) = try await client.sendRequest( + path: "/users/me/api-keys", + method: "POST", + body: body, + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse API key response") + } + + return UserApiKeyFirstView(from: json) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift new file mode 100644 index 0000000000..cdb2a8492b --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A permission granted to a user within a team or project +public struct TeamPermission: Sendable { + public let id: String +} + +/// A project-level permission +public struct ProjectPermission: Sendable { + public let id: String +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift new file mode 100644 index 0000000000..69417d8f3b --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Project information +public struct Project: Sendable { + public let id: String + public let displayName: String + public let config: ProjectConfig + + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String ?? "" + + if let configJson = json["config"] as? [String: Any] { + self.config = ProjectConfig(from: configJson) + } else { + self.config = ProjectConfig( + signUpEnabled: false, + credentialEnabled: false, + magicLinkEnabled: false, + passkeyEnabled: false, + oauthProviders: [], + clientTeamCreationEnabled: false, + clientUserDeletionEnabled: false, + allowUserApiKeys: false, + allowTeamApiKeys: false + ) + } + } +} + +/// Project configuration +public struct ProjectConfig: Sendable { + public let signUpEnabled: Bool + public let credentialEnabled: Bool + public let magicLinkEnabled: Bool + public let passkeyEnabled: Bool + public let oauthProviders: [OAuthProviderConfig] + public let clientTeamCreationEnabled: Bool + public let clientUserDeletionEnabled: Bool + public let allowUserApiKeys: Bool + public let allowTeamApiKeys: Bool + + init(from json: [String: Any]) { + self.signUpEnabled = json["sign_up_enabled"] as? Bool ?? false + self.credentialEnabled = json["credential_enabled"] as? Bool ?? false + self.magicLinkEnabled = json["magic_link_enabled"] as? Bool ?? false + self.passkeyEnabled = json["passkey_enabled"] as? Bool ?? false + self.clientTeamCreationEnabled = json["client_team_creation_enabled"] as? Bool ?? false + self.clientUserDeletionEnabled = json["client_user_deletion_enabled"] as? Bool ?? false + self.allowUserApiKeys = json["allow_user_api_keys"] as? Bool ?? false + self.allowTeamApiKeys = json["allow_team_api_keys"] as? Bool ?? false + + if let providers = json["enabled_oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { OAuthProviderConfig(id: $0["id"] as? String ?? "") } + } else if let providers = json["oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { OAuthProviderConfig(id: $0["id"] as? String ?? "") } + } else { + self.oauthProviders = [] + } + } + + init( + signUpEnabled: Bool, + credentialEnabled: Bool, + magicLinkEnabled: Bool, + passkeyEnabled: Bool, + oauthProviders: [OAuthProviderConfig], + clientTeamCreationEnabled: Bool, + clientUserDeletionEnabled: Bool, + allowUserApiKeys: Bool, + allowTeamApiKeys: Bool + ) { + self.signUpEnabled = signUpEnabled + self.credentialEnabled = credentialEnabled + self.magicLinkEnabled = magicLinkEnabled + self.passkeyEnabled = passkeyEnabled + self.oauthProviders = oauthProviders + self.clientTeamCreationEnabled = clientTeamCreationEnabled + self.clientUserDeletionEnabled = clientUserDeletionEnabled + self.allowUserApiKeys = allowUserApiKeys + self.allowTeamApiKeys = allowTeamApiKeys + } +} + +public struct OAuthProviderConfig: Sendable { + public let id: String +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift new file mode 100644 index 0000000000..93d973efd7 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift @@ -0,0 +1,176 @@ +import Foundation + +/// Server-side team with elevated access and server metadata +public actor ServerTeam { + private let client: APIClient + + public nonisolated let id: String + public private(set) var displayName: String + public private(set) var profileImageUrl: String? + public private(set) var clientMetadata: [String: Any] + public private(set) var clientReadOnlyMetadata: [String: Any] + public private(set) var serverMetadata: [String: Any] + public let createdAt: Date + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String ?? "" + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? [:] + + let createdMillis = json["created_at_millis"] as? Int64 ?? 0 + self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + } + + // MARK: - Update + + public func update( + displayName: String? = nil, + profileImageUrl: String? = nil, + clientMetadata: [String: Any]? = nil, + clientReadOnlyMetadata: [String: Any]? = nil, + serverMetadata: [String: Any]? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let url = profileImageUrl { body["profile_image_url"] = url } + if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta } + if let clientReadOnly = clientReadOnlyMetadata { body["client_read_only_metadata"] = clientReadOnly } + if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)", + method: "PATCH", + body: body, + serverOnly: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.displayName = json["display_name"] as? String ?? self.displayName + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? self.serverMetadata + } + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/teams/\(id)", + method: "DELETE", + serverOnly: true + ) + } + + // MARK: - Users + + public func listUsers() async throws -> [TeamUser] { + let (data, _) = try await client.sendRequest( + path: "/users?team_id=\(id)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamUser(from: $0) } + } + + public func addUser(id userId: String) async throws { + _ = try await client.sendRequest( + path: "/team-memberships/\(id)/\(userId)", + method: "POST", + serverOnly: true + ) + } + + public func removeUser(id userId: String) async throws { + _ = try await client.sendRequest( + path: "/team-memberships/\(id)/\(userId)", + method: "DELETE", + serverOnly: true + ) + } + + // MARK: - Invitations + + public func inviteUser(email: String, callbackUrl: String? = nil) async throws { + var body: [String: Any] = [ + "email": email, + "team_id": id + ] + if let url = callbackUrl { body["callback_url"] = url } + + _ = try await client.sendRequest( + path: "/team-invitations/send-code", + method: "POST", + body: body, + serverOnly: true + ) + } + + public func listInvitations() async throws -> [TeamInvitation] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/invitations", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamInvitation(client: client, teamId: id, json: $0) } + } + + // MARK: - API Keys + + public func listApiKeys() async throws -> [TeamApiKey] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamApiKey(from: $0) } + } + + public func createApiKey( + description: String, + expiresAt: Date? = nil, + scope: String? = nil + ) async throws -> TeamApiKeyFirstView { + var body: [String: Any] = ["description": description] + if let expiresAt = expiresAt { + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + } + if let scope = scope { body["scope"] = scope } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "POST", + body: body, + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse API key response") + } + + return TeamApiKeyFirstView(from: json) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift new file mode 100644 index 0000000000..3e7f6588c0 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift @@ -0,0 +1,262 @@ +import Foundation + +/// Server-side user with elevated access and server metadata +public actor ServerUser { + private let client: APIClient + + public nonisolated let id: String + public private(set) var displayName: String? + public private(set) var primaryEmail: String? + public private(set) var primaryEmailVerified: Bool + public private(set) var profileImageUrl: String? + public let signedUpAt: Date + public private(set) var lastActiveAt: Date? + public private(set) var clientMetadata: [String: Any] + public private(set) var clientReadOnlyMetadata: [String: Any] + public private(set) var serverMetadata: [String: Any] + public private(set) var hasPassword: Bool + public private(set) var emailAuthEnabled: Bool + public private(set) var otpAuthEnabled: Bool + public private(set) var passkeyAuthEnabled: Bool + public private(set) var isMultiFactorRequired: Bool + public let isAnonymous: Bool + public let isRestricted: Bool + public let restrictedReason: User.RestrictedReason? + public let oauthProviders: [User.OAuthProviderInfo] + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String + self.primaryEmail = json["primary_email"] as? String + self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? false + self.profileImageUrl = json["profile_image_url"] as? String + + let signedUpMillis = json["signed_up_at_millis"] as? Int64 ?? 0 + self.signedUpAt = Date(timeIntervalSince1970: Double(signedUpMillis) / 1000.0) + + if let lastActiveMillis = json["last_active_at_millis"] as? Int64 { + self.lastActiveAt = Date(timeIntervalSince1970: Double(lastActiveMillis) / 1000.0) + } else { + self.lastActiveAt = nil + } + + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? [:] + + self.hasPassword = json["has_password"] as? Bool ?? false + self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? json["primary_email_auth_enabled"] as? Bool ?? false + self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? false + self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? false + self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? false + self.isAnonymous = json["is_anonymous"] as? Bool ?? false + self.isRestricted = json["is_restricted"] as? Bool ?? false + + if let reason = json["restricted_reason"] as? [String: Any], + let type = reason["type"] as? String { + self.restrictedReason = User.RestrictedReason(type: type) + } else { + self.restrictedReason = nil + } + + if let providers = json["oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { User.OAuthProviderInfo(id: $0["id"] as? String ?? "") } + } else { + self.oauthProviders = [] + } + } + + // MARK: - Update + + public func update( + displayName: String? = nil, + clientMetadata: [String: Any]? = nil, + clientReadOnlyMetadata: [String: Any]? = nil, + serverMetadata: [String: Any]? = nil, + selectedTeamId: String? = nil, + primaryEmail: String? = nil, + primaryEmailAuthEnabled: Bool? = nil, + primaryEmailVerified: Bool? = nil, + profileImageUrl: String? = nil, + password: String? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta } + if let clientReadOnly = clientReadOnlyMetadata { body["client_read_only_metadata"] = clientReadOnly } + if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta } + if let teamId = selectedTeamId { body["selected_team_id"] = teamId } + if let email = primaryEmail { body["primary_email"] = email } + if let authEnabled = primaryEmailAuthEnabled { body["primary_email_auth_enabled"] = authEnabled } + if let verified = primaryEmailVerified { body["primary_email_verified"] = verified } + if let url = profileImageUrl { body["profile_image_url"] = url } + if let password = password { body["password"] = password } + + let (data, _) = try await client.sendRequest( + path: "/users/\(id)", + method: "PATCH", + body: body, + serverOnly: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.displayName = json["display_name"] as? String + self.primaryEmail = json["primary_email"] as? String + self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? self.primaryEmailVerified + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? self.serverMetadata + self.hasPassword = json["has_password"] as? Bool ?? self.hasPassword + self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? json["primary_email_auth_enabled"] as? Bool ?? self.emailAuthEnabled + self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? self.otpAuthEnabled + self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? self.passkeyAuthEnabled + self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? self.isMultiFactorRequired + } + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/users/\(id)", + method: "DELETE", + serverOnly: true + ) + } + + // MARK: - Password + + /// Set a password for this user (server-side). + /// Unlike client-side setPassword, this uses the user update endpoint. + public func setPassword(_ password: String) async throws { + try await update(password: password) + } + + // MARK: - Teams + + public func listTeams() async throws -> [ServerTeam] { + let (data, _) = try await client.sendRequest( + path: "/users/\(id)/teams", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ServerTeam(client: client, json: $0) } + } + + // MARK: - Contact Channels + + public func listContactChannels() async throws -> [ContactChannel] { + let (data, _) = try await client.sendRequest( + path: "/contact-channels?user_id=\(id)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ContactChannel(client: client, json: $0) } + } + + // MARK: - Permissions + + public func grantPermission(id permissionId: String, teamId: String? = nil) async throws { + var body: [String: Any] = [ + "user_id": id, + "permission_id": permissionId + ] + if let teamId = teamId { body["team_id"] = teamId } + + _ = try await client.sendRequest( + path: "/permissions/grant", + method: "POST", + body: body, + serverOnly: true + ) + } + + public func revokePermission(id permissionId: String, teamId: String? = nil) async throws { + var body: [String: Any] = [ + "user_id": id, + "permission_id": permissionId + ] + if let teamId = teamId { body["team_id"] = teamId } + + _ = try await client.sendRequest( + path: "/permissions/revoke", + method: "POST", + body: body, + serverOnly: true + ) + } + + public func hasPermission(id permissionId: String, teamId: String? = nil) async throws -> Bool { + var query = "user_id=\(id)&permission_id=\(permissionId)" + if let teamId = teamId { query += "&team_id=\(teamId)" } + + let (data, _) = try await client.sendRequest( + path: "/permissions/check?\(query)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + + return json["has_permission"] as? Bool ?? false + } + + public func listPermissions(teamId: String? = nil, recursive: Bool = true) async throws -> [TeamPermission] { + var query = "user_id=\(id)&recursive=\(recursive)" + if let teamId = teamId { query += "&team_id=\(teamId)" } + + let (data, _) = try await client.sendRequest( + path: "/users/\(id)/permissions?\(query)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamPermission(id: $0["id"] as? String ?? "") } + } + + // MARK: - Sessions + + public func getActiveSessions() async throws -> [ActiveSession] { + let (data, _) = try await client.sendRequest( + path: "/users/\(id)/sessions", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ActiveSession(from: $0) } + } + + public func revokeSession(id sessionId: String) async throws { + _ = try await client.sendRequest( + path: "/users/\(id)/sessions/\(sessionId)", + method: "DELETE", + serverOnly: true + ) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift new file mode 100644 index 0000000000..7e5fc316a2 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift @@ -0,0 +1,55 @@ +import Foundation + +/// An active login session +public struct ActiveSession: Sendable { + public let id: String + public let userId: String + public let createdAt: Date + public let isImpersonation: Bool + public let lastUsedAt: Date? + public let isCurrentSession: Bool + public let geoInfo: GeoInfo? + + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.userId = json["user_id"] as? String ?? "" + + let createdMillis = json["created_at"] as? Int64 ?? json["created_at_millis"] as? Int64 ?? 0 + self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + + self.isImpersonation = json["is_impersonation"] as? Bool ?? false + + if let lastUsedMillis = json["last_used_at"] as? Int64 ?? json["last_used_at_millis"] as? Int64 { + self.lastUsedAt = Date(timeIntervalSince1970: Double(lastUsedMillis) / 1000.0) + } else { + self.lastUsedAt = nil + } + + self.isCurrentSession = json["is_current_session"] as? Bool ?? false + + if let geoJson = json["last_used_at_end_user_ip_info"] as? [String: Any] ?? json["geo_info"] as? [String: Any] { + self.geoInfo = GeoInfo(from: geoJson) + } else { + self.geoInfo = nil + } + } +} + +/// Geographic information from IP address +public struct GeoInfo: Sendable { + public let city: String? + public let region: String? + public let country: String? + public let countryName: String? + public let latitude: Double? + public let longitude: Double? + + init(from json: [String: Any]) { + self.city = json["city"] as? String + self.region = json["region"] as? String + self.country = json["country"] as? String + self.countryName = json["country_name"] as? String + self.latitude = json["latitude"] as? Double + self.longitude = json["longitude"] as? Double + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift new file mode 100644 index 0000000000..598bde101c --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift @@ -0,0 +1,210 @@ +import Foundation + +/// A team/organization that users can belong to +public actor Team { + private let client: APIClient + + public nonisolated let id: String + public private(set) var displayName: String + public private(set) var profileImageUrl: String? + public private(set) var clientMetadata: [String: Any] + public private(set) var clientReadOnlyMetadata: [String: Any] + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String ?? "" + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + } + + // MARK: - Update + + public func update( + displayName: String? = nil, + profileImageUrl: String? = nil, + clientMetadata: [String: Any]? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let profileImageUrl = profileImageUrl { body["profile_image_url"] = profileImageUrl } + if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)", + method: "PATCH", + body: body, + authenticated: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.displayName = json["display_name"] as? String ?? self.displayName + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata + } + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/teams/\(id)", + method: "DELETE", + authenticated: true + ) + } + + // MARK: - Invite + + public func inviteUser(email: String, callbackUrl: String? = nil) async throws { + var body: [String: Any] = [ + "email": email, + "team_id": id + ] + if let callbackUrl = callbackUrl { + body["callback_url"] = callbackUrl + } + + _ = try await client.sendRequest( + path: "/team-invitations/send-code", + method: "POST", + body: body, + authenticated: true + ) + } + + // MARK: - List Users + + public func listUsers() async throws -> [TeamUser] { + let (data, _) = try await client.sendRequest( + path: "/team-member-profiles?team_id=\(id)", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamUser(from: $0) } + } + + // MARK: - Invitations + + public func listInvitations() async throws -> [TeamInvitation] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/invitations", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamInvitation(client: client, teamId: id, json: $0) } + } + + // MARK: - API Keys + + public func listApiKeys() async throws -> [TeamApiKey] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamApiKey(from: $0) } + } + + public func createApiKey( + description: String, + expiresAt: Date? = nil, + scope: String? = nil + ) async throws -> TeamApiKeyFirstView { + var body: [String: Any] = ["description": description] + if let expiresAt = expiresAt { + body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + } + if let scope = scope { body["scope"] = scope } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "POST", + body: body, + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse API key response") + } + + return TeamApiKeyFirstView(from: json) + } +} + +// MARK: - Supporting Types + +public struct TeamUser: Sendable { + public let id: String + public let teamProfile: TeamMemberProfile + + init(from json: [String: Any]) { + // Try both "id" (from /users?team_id=) and "user_id" (from other endpoints) + self.id = json["id"] as? String ?? json["user_id"] as? String ?? "" + + if let profile = json["team_profile"] as? [String: Any] { + self.teamProfile = TeamMemberProfile( + displayName: profile["display_name"] as? String, + profileImageUrl: profile["profile_image_url"] as? String + ) + } else { + // If no team_profile, use display_name from user itself + self.teamProfile = TeamMemberProfile( + displayName: json["display_name"] as? String, + profileImageUrl: json["profile_image_url"] as? String + ) + } + } +} + +public struct TeamMemberProfile: Sendable { + public let displayName: String? + public let profileImageUrl: String? +} + +public actor TeamInvitation { + private let client: APIClient + private let teamId: String + + public nonisolated let id: String + public let recipientEmail: String? + public let expiresAt: Date + + init(client: APIClient, teamId: String, json: [String: Any]) { + self.client = client + self.teamId = teamId + self.id = json["id"] as? String ?? "" + self.recipientEmail = json["recipient_email"] as? String + + let millis = json["expires_at_millis"] as? Int64 ?? 0 + self.expiresAt = Date(timeIntervalSince1970: Double(millis) / 1000.0) + } + + public func revoke() async throws { + _ = try await client.sendRequest( + path: "/teams/\(teamId)/invitations/\(id)", + method: "DELETE", + authenticated: true + ) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/User.swift b/sdks/implementations/swift/Sources/StackAuth/Models/User.swift new file mode 100644 index 0000000000..b65a4c5987 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/User.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Base user properties visible to clients +/// Note: [String: Any] is not Sendable but we accept this for JSON data +public struct User: @unchecked Sendable { + public let id: String + public let displayName: String? + public let primaryEmail: String? + public let primaryEmailVerified: Bool + public let profileImageUrl: String? + public let signedUpAt: Date + public let clientMetadata: [String: Any] + public let clientReadOnlyMetadata: [String: Any] + public let hasPassword: Bool + public let emailAuthEnabled: Bool + public let otpAuthEnabled: Bool + public let passkeyAuthEnabled: Bool + public let isMultiFactorRequired: Bool + public let isAnonymous: Bool + public let isRestricted: Bool + public let restrictedReason: RestrictedReason? + public let oauthProviders: [OAuthProviderInfo] + + public struct RestrictedReason: Sendable { + public let type: String // "anonymous" | "email_not_verified" + } + + public struct OAuthProviderInfo: Sendable { + public let id: String + } +} + +// Make User Sendable by using a wrapper for the metadata +extension User { + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String + self.primaryEmail = json["primary_email"] as? String + self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? false + self.profileImageUrl = json["profile_image_url"] as? String + + let millis = json["signed_up_at_millis"] as? Int64 ?? 0 + self.signedUpAt = Date(timeIntervalSince1970: Double(millis) / 1000.0) + + // Note: These are not truly Sendable but we accept the risk for JSON data + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + + self.hasPassword = json["has_password"] as? Bool ?? false + self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? false + self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? false + self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? false + self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? false + self.isAnonymous = json["is_anonymous"] as? Bool ?? false + self.isRestricted = json["is_restricted"] as? Bool ?? false + + if let reason = json["restricted_reason"] as? [String: Any], + let type = reason["type"] as? String { + self.restrictedReason = RestrictedReason(type: type) + } else { + self.restrictedReason = nil + } + + if let providers = json["oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { OAuthProviderInfo(id: $0["id"] as? String ?? "") } + } else { + self.oauthProviders = [] + } + } +} + +/// Partial user info extracted from JWT token +public struct TokenPartialUser: Sendable { + public let id: String + public let displayName: String? + public let primaryEmail: String? + public let primaryEmailVerified: Bool + public let isAnonymous: Bool + public let isRestricted: Bool + public let restrictedReason: User.RestrictedReason? +} diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift new file mode 100644 index 0000000000..4c472e79a5 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -0,0 +1,665 @@ +import Foundation +import CryptoKit +#if canImport(AuthenticationServices) +import AuthenticationServices +#endif + +/// Handler URLs configuration +public struct HandlerUrls: Sendable { + public var home: String + public var signIn: String + public var signUp: String + public var signOut: String + public var afterSignIn: String + public var afterSignUp: String + public var afterSignOut: String + public var emailVerification: String + public var passwordReset: String + public var forgotPassword: String + public var magicLinkCallback: String + public var oauthCallback: String + public var accountSettings: String + public var onboarding: String + public var teamInvitation: String + public var mfa: String + public var error: String + + public init( + home: String = "/", + signIn: String = "/handler/sign-in", + signUp: String = "/handler/sign-up", + signOut: String = "/handler/sign-out", + afterSignIn: String = "/", + afterSignUp: String = "/", + afterSignOut: String = "/", + emailVerification: String = "/handler/email-verification", + passwordReset: String = "/handler/password-reset", + forgotPassword: String = "/handler/forgot-password", + magicLinkCallback: String = "/handler/magic-link-callback", + oauthCallback: String = "/handler/oauth-callback", + accountSettings: String = "/handler/account-settings", + onboarding: String = "/handler/onboarding", + teamInvitation: String = "/handler/team-invitation", + mfa: String = "/handler/mfa", + error: String = "/handler/error" + ) { + self.home = home + self.signIn = signIn + self.signUp = signUp + self.signOut = signOut + self.afterSignIn = afterSignIn + self.afterSignUp = afterSignUp + self.afterSignOut = afterSignOut + self.emailVerification = emailVerification + self.passwordReset = passwordReset + self.forgotPassword = forgotPassword + self.magicLinkCallback = magicLinkCallback + self.oauthCallback = oauthCallback + self.accountSettings = accountSettings + self.onboarding = onboarding + self.teamInvitation = teamInvitation + self.mfa = mfa + self.error = error + } +} + +/// OAuth URL result +public struct OAuthUrlResult: Sendable { + public let url: URL + public let state: String + public let codeVerifier: String +} + +/// Get user options +public enum GetUserOr: Sendable { + case returnNull + case redirect + case `throw` + case anonymous +} + +/// The main Stack Auth client +public actor StackClientApp { + public let projectId: String + public let urls: HandlerUrls + + let client: APIClient + private let baseUrl: String + + public init( + projectId: String, + publishableClientKey: String, + baseUrl: String = "https://api.stack-auth.com", + tokenStore: TokenStore = .keychain, + urls: HandlerUrls = HandlerUrls(), + noAutomaticPrefetch: Bool = false + ) { + self.projectId = projectId + self.baseUrl = baseUrl + self.urls = urls + + let store: any TokenStoreProtocol + switch tokenStore { + case .keychain: + store = KeychainTokenStore(projectId: projectId) + case .memory: + store = MemoryTokenStore() + case .explicit(let accessToken, let refreshToken): + store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) + case .none: + store = NullTokenStore() + case .custom(let customStore): + store = customStore + } + + self.client = APIClient( + baseUrl: baseUrl, + projectId: projectId, + publishableClientKey: publishableClientKey, + tokenStore: store + ) + + // Prefetch project info + if !noAutomaticPrefetch { + Task { + _ = try? await self.getProject() + } + } + } + + // MARK: - OAuth + + /// Get the OAuth authorization URL without redirecting + public func getOAuthUrl( + provider: String, + redirectUrl: String? = nil, + state: String? = nil, + codeVerifier: String? = nil + ) async throws -> OAuthUrlResult { + let actualState = state ?? generateRandomString(length: 32) + let actualCodeVerifier = codeVerifier ?? generateCodeVerifier() + let codeChallenge = generateCodeChallenge(from: actualCodeVerifier) + + let callbackUrl = redirectUrl ?? urls.oauthCallback + + var components = URLComponents(string: "\(baseUrl)/api/v1/auth/oauth/authorize/\(provider.lowercased())")! + let publishableKey = await client.publishableClientKey + components.queryItems = [ + URLQueryItem(name: "client_id", value: projectId), + URLQueryItem(name: "client_secret", value: publishableKey), + URLQueryItem(name: "redirect_uri", value: callbackUrl), + URLQueryItem(name: "scope", value: "legacy"), + URLQueryItem(name: "state", value: actualState), + URLQueryItem(name: "grant_type", value: "authorization_code"), + URLQueryItem(name: "code_challenge", value: codeChallenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "type", value: "authenticate"), + URLQueryItem(name: "error_redirect_url", value: urls.error) + ] + + // Add access token if user is already logged in + if let accessToken = await client.getAccessToken() { + components.queryItems?.append(URLQueryItem(name: "token", value: accessToken)) + } + + guard let url = components.url else { + throw StackAuthError(code: "invalid_url", message: "Failed to construct OAuth URL") + } + + return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier) + } + + #if canImport(AuthenticationServices) && !os(watchOS) + /// Sign in with OAuth using ASWebAuthenticationSession + @MainActor + public func signInWithOAuth( + provider: String, + presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil + ) async throws { + let oauth = try await getOAuthUrl(provider: provider) + + let callbackScheme = "stackauth-\(projectId)" + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let session = ASWebAuthenticationSession( + url: oauth.url, + callbackURLScheme: callbackScheme + ) { callbackUrl, error in + if let error = error { + if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue { + continuation.resume(throwing: StackAuthError(code: "oauth_cancelled", message: "User cancelled OAuth")) + } else { + continuation.resume(throwing: OAuthError(code: "oauth_error", message: error.localizedDescription)) + } + return + } + + guard let callbackUrl = callbackUrl else { + continuation.resume(throwing: OAuthError(code: "oauth_error", message: "No callback URL received")) + return + } + + Task { + do { + try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + + session.prefersEphemeralWebBrowserSession = false + + #if os(iOS) || os(macOS) + if let provider = presentationContextProvider { + session.presentationContextProvider = provider + } + #endif + + session.start() + } + } + #endif + + /// Complete the OAuth flow with the callback URL + public func callOAuthCallback(url: URL, codeVerifier: String) async throws { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + + guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else { + if let error = components?.queryItems?.first(where: { $0.name == "error" })?.value { + let description = components?.queryItems?.first(where: { $0.name == "error_description" })?.value ?? "OAuth error" + throw OAuthError(code: error, message: description) + } + throw OAuthError(code: "missing_code", message: "No authorization code in callback URL") + } + + // Exchange code for tokens + let tokenUrl = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")! + var request = URLRequest(url: tokenUrl) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + + let publishableKey = await client.publishableClientKey + let body = [ + "grant_type=authorization_code", + "code=\(code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? code)", + "redirect_uri=\(urls.oauthCallback.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? urls.oauthCallback)", + "code_verifier=\(codeVerifier)", + "client_id=\(projectId)", + "client_secret=\(publishableKey)" + ].joined(separator: "&") + + request.httpBody = body.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthError(code: "invalid_response", message: "Invalid HTTP response") + } + + if httpResponse.statusCode != 200 { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorCode = json["error"] as? String { + let message = json["error_description"] as? String ?? "Token exchange failed" + throw OAuthError(code: errorCode, message: message) + } + throw OAuthError(code: "token_exchange_failed", message: "HTTP \(httpResponse.statusCode)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String else { + throw OAuthError(code: "parse_error", message: "Failed to parse token response") + } + + let refreshToken = json["refresh_token"] as? String + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - Credential Auth + + public func signInWithCredential(email: String, password: String) async throws { + let (data, _) = try await client.sendRequest( + path: "/auth/password/sign-in", + method: "POST", + body: ["email": email, "password": password] + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse sign-in response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + public func signUpWithCredential( + email: String, + password: String, + verificationCallbackUrl: String? = nil + ) async throws { + var body: [String: Any] = ["email": email, "password": password] + if let callbackUrl = verificationCallbackUrl { + body["verification_callback_url"] = callbackUrl + } + + let (data, _) = try await client.sendRequest( + path: "/auth/password/sign-up", + method: "POST", + body: body + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse sign-up response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - Magic Link + + public func sendMagicLinkEmail(email: String, callbackUrl: String? = nil) async throws -> String { + var body: [String: Any] = ["email": email] + if let callbackUrl = callbackUrl { + body["callback_url"] = callbackUrl + } else { + body["callback_url"] = urls.magicLinkCallback + } + + let (data, _) = try await client.sendRequest( + path: "/auth/otp/send-sign-in-code", + method: "POST", + body: body + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let nonce = json["nonce"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse magic link response") + } + + return nonce + } + + public func signInWithMagicLink(code: String) async throws { + let (data, _) = try await client.sendRequest( + path: "/auth/otp/sign-in", + method: "POST", + body: ["code": code] + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse magic link sign-in response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - MFA + + public func signInWithMfa(totp: String, code: String) async throws { + let (data, _) = try await client.sendRequest( + path: "/auth/mfa/sign-in", + method: "POST", + body: [ + "type": "totp", + "totp": totp, + "code": code + ] + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse MFA sign-in response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - Password Reset + + public func sendForgotPasswordEmail(email: String, callbackUrl: String? = nil) async throws { + var body: [String: Any] = ["email": email] + body["callback_url"] = callbackUrl ?? urls.passwordReset + + _ = try await client.sendRequest( + path: "/auth/password/send-reset-code", + method: "POST", + body: body + ) + } + + public func resetPassword(code: String, password: String) async throws { + _ = try await client.sendRequest( + path: "/auth/password/reset", + method: "POST", + body: ["code": code, "password": password] + ) + } + + public func verifyPasswordResetCode(_ code: String) async throws { + _ = try await client.sendRequest( + path: "/auth/password/reset/check-code", + method: "POST", + body: ["code": code] + ) + } + + // MARK: - Email Verification + + public func verifyEmail(code: String) async throws { + _ = try await client.sendRequest( + path: "/contact-channels/verify", + method: "POST", + body: ["code": code] + ) + } + + // MARK: - Team Invitations + + public func acceptTeamInvitation(code: String) async throws { + _ = try await client.sendRequest( + path: "/team-invitations/accept", + method: "POST", + body: ["code": code], + authenticated: true + ) + } + + public func verifyTeamInvitationCode(_ code: String) async throws { + _ = try await client.sendRequest( + path: "/team-invitations/accept/check-code", + method: "POST", + body: ["code": code], + authenticated: true + ) + } + + public func getTeamInvitationDetails(code: String) async throws -> String { + let (data, _) = try await client.sendRequest( + path: "/team-invitations/accept/details", + method: "POST", + body: ["code": code], + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let teamDisplayName = json["team_display_name"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse team invitation details") + } + + return teamDisplayName + } + + // MARK: - User + + public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false) async throws -> CurrentUser? { + // Validate mutually exclusive options + if or == .anonymous && !includeRestricted { + throw StackAuthError( + code: "invalid_options", + message: "Cannot use { or: 'anonymous' } with { includeRestricted: false }" + ) + } + + let includeAnonymous = or == .anonymous + let effectiveIncludeRestricted = includeRestricted || includeAnonymous + + // Check if we have tokens + let hasTokens = await client.getAccessToken() != nil + + if !hasTokens { + switch or { + case .returnNull: + return nil + case .redirect: + throw StackAuthError(code: "redirect_not_supported", message: "Redirects are not supported in Swift SDK") + case .throw: + throw UserNotSignedInError() + case .anonymous: + try await signUpAnonymously() + } + } + + do { + let (data, _) = try await client.sendRequest( + path: "/users/me", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + let user = CurrentUser(client: client, json: json) + + // Check if we should return this user + if await user.isAnonymous && !includeAnonymous { + return handleNoUser(or: or) + } + + if await user.isRestricted && !effectiveIncludeRestricted { + return handleNoUser(or: or) + } + + return user + + } catch { + return handleNoUser(or: or) + } + } + + private func handleNoUser(or: GetUserOr) -> CurrentUser? { + switch or { + case .returnNull, .anonymous: + return nil + case .redirect: + // Can't redirect in Swift + return nil + case .throw: + // Already thrown + return nil + } + } + + private func signUpAnonymously() async throws { + let (data, _) = try await client.sendRequest( + path: "/auth/anonymous/sign-up", + method: "POST" + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse anonymous sign-up response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - Project + + public func getProject() async throws -> Project { + let (data, _) = try await client.sendRequest( + path: "/projects/current", + method: "GET" + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse project response") + } + + return Project(from: json) + } + + // MARK: - Partial User + + public func getPartialUser() async -> TokenPartialUser? { + guard let accessToken = await client.getAccessToken() else { + return nil + } + + // Decode JWT + let parts = accessToken.split(separator: ".") + guard parts.count >= 2 else { return nil } + + var base64 = String(parts[1]) + // Add padding if needed + while base64.count % 4 != 0 { + base64 += "=" + } + // Replace URL-safe characters + base64 = base64.replacingOccurrences(of: "-", with: "+") + base64 = base64.replacingOccurrences(of: "_", with: "/") + + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + var restrictedReason: User.RestrictedReason? = nil + if let reason = json["restricted_reason"] as? [String: Any], + let type = reason["type"] as? String { + restrictedReason = User.RestrictedReason(type: type) + } + + return TokenPartialUser( + id: json["sub"] as? String ?? "", + displayName: json["name"] as? String, + primaryEmail: json["email"] as? String, + primaryEmailVerified: json["email_verified"] as? Bool ?? false, + isAnonymous: json["is_anonymous"] as? Bool ?? false, + isRestricted: json["is_restricted"] as? Bool ?? false, + restrictedReason: restrictedReason + ) + } + + // MARK: - Sign Out + + public func signOut() async throws { + _ = try? await client.sendRequest( + path: "/auth/sessions/current", + method: "DELETE", + authenticated: true + ) + await client.clearTokens() + } + + // MARK: - Tokens + + public func getAccessToken() async -> String? { + return await client.getAccessToken() + } + + public func getRefreshToken() async -> String? { + return await client.getRefreshToken() + } + + public func getAuthHeaders() async -> [String: String] { + let accessToken = await client.getAccessToken() + let refreshToken = await client.getRefreshToken() + + let json: [String: Any?] = [ + "accessToken": accessToken, + "refreshToken": refreshToken + ] + + if let data = try? JSONSerialization.data(withJSONObject: json), + let string = String(data: data, encoding: .utf8) { + return ["x-stack-auth": string] + } + + return ["x-stack-auth": "{}"] + } + + // MARK: - PKCE Helpers + + private func generateRandomString(length: Int) -> String { + let characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0.. String { + return generateRandomString(length: 64) + } + + private func generateCodeChallenge(from verifier: String) -> String { + let data = Data(verifier.utf8) + let hash = SHA256.hash(data: data) + let base64 = Data(hash).base64EncodedString() + + // Convert to base64url + return base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift new file mode 100644 index 0000000000..4e2d7dc490 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift @@ -0,0 +1,266 @@ +import Foundation + +/// Server-side Stack Auth client with elevated privileges +public actor StackServerApp { + public let projectId: String + + let client: APIClient + + public init( + projectId: String, + publishableClientKey: String, + secretServerKey: String, + baseUrl: String = "https://api.stack-auth.com" + ) { + self.projectId = projectId + + self.client = APIClient( + baseUrl: baseUrl, + projectId: projectId, + publishableClientKey: publishableClientKey, + secretServerKey: secretServerKey, + tokenStore: NullTokenStore() + ) + } + + // MARK: - Users + + public func listUsers( + limit: Int? = nil, + cursor: String? = nil, + orderBy: String? = nil, + descending: Bool? = nil + ) async throws -> PaginatedResult { + var query: [String] = [] + if let limit = limit { query.append("limit=\(limit)") } + if let cursor = cursor { query.append("cursor=\(cursor)") } + if let orderBy = orderBy { query.append("order_by=\(orderBy)") } + if let desc = descending { query.append("desc=\(desc)") } + + var path = "/users" + if !query.isEmpty { + path += "?" + query.joined(separator: "&") + } + + let (data, _) = try await client.sendRequest( + path: path, + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return PaginatedResult(items: [], pagination: Pagination(hasPreviousPage: false, hasNextPage: false, startCursor: nil, endCursor: nil)) + } + + let pagination = parsePagination(from: json) + return PaginatedResult( + items: items.map { ServerUser(client: client, json: $0) }, + pagination: pagination + ) + } + + public func getUser(id userId: String) async throws -> ServerUser? { + do { + let (data, _) = try await client.sendRequest( + path: "/users/\(userId)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + return ServerUser(client: client, json: json) + } catch let error as StackAuthErrorProtocol where error.code == "USER_NOT_FOUND" { + return nil + } + } + + public func createUser( + email: String? = nil, + password: String? = nil, + displayName: String? = nil, + primaryEmailAuthEnabled: Bool = false, + primaryEmailVerified: Bool = false, + clientMetadata: [String: Any]? = nil, + serverMetadata: [String: Any]? = nil, + otpAuthEnabled: Bool = false, + totpSecretBase32: String? = nil, + selectedTeamId: String? = nil, + profileImageUrl: String? = nil + ) async throws -> ServerUser { + var body: [String: Any] = [:] + if let email = email { body["primary_email"] = email } + if let password = password { body["password"] = password } + if let displayName = displayName { body["display_name"] = displayName } + body["primary_email_auth_enabled"] = primaryEmailAuthEnabled + body["primary_email_verified"] = primaryEmailVerified + if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata } + if let serverMetadata = serverMetadata { body["server_metadata"] = serverMetadata } + body["otp_auth_enabled"] = otpAuthEnabled + if let totp = totpSecretBase32 { body["totp_secret_base32"] = totp } + if let teamId = selectedTeamId { body["selected_team_id"] = teamId } + if let url = profileImageUrl { body["profile_image_url"] = url } + + let (data, _) = try await client.sendRequest( + path: "/users", + method: "POST", + body: body, + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse user response") + } + + return ServerUser(client: client, json: json) + } + + // MARK: - Teams + + public func listTeams( + userId: String? = nil + ) async throws -> [ServerTeam] { + var query: [String] = [] + if let userId = userId { query.append("user_id=\(userId)") } + + var path = "/teams" + if !query.isEmpty { + path += "?" + query.joined(separator: "&") + } + + let (data, _) = try await client.sendRequest( + path: path, + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ServerTeam(client: client, json: $0) } + } + + public func getTeam(id teamId: String) async throws -> ServerTeam? { + do { + let (data, _) = try await client.sendRequest( + path: "/teams/\(teamId)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + return ServerTeam(client: client, json: json) + } catch let error as StackAuthErrorProtocol where error.code == "TEAM_NOT_FOUND" { + return nil + } + } + + public func createTeam( + displayName: String, + creatorUserId: String? = nil, + profileImageUrl: String? = nil, + clientMetadata: [String: Any]? = nil, + serverMetadata: [String: Any]? = nil + ) async throws -> ServerTeam { + var body: [String: Any] = ["display_name": displayName] + if let creatorId = creatorUserId { body["creator_user_id"] = creatorId } + if let url = profileImageUrl { body["profile_image_url"] = url } + if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta } + if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta } + + let (data, _) = try await client.sendRequest( + path: "/teams", + method: "POST", + body: body, + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse team response") + } + + return ServerTeam(client: client, json: json) + } + + // MARK: - Project + + public func getProject() async throws -> Project { + let (data, _) = try await client.sendRequest( + path: "/projects/current", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse project response") + } + + return Project(from: json) + } + + // MARK: - Create Session (Impersonation) + + public func createSession(userId: String, expiresInSeconds: Int = 3600) async throws -> SessionTokens { + let body: [String: Any] = [ + "user_id": userId, + "expires_in_millis": expiresInSeconds * 1000 + ] + + let (data, _) = try await client.sendRequest( + path: "/auth/sessions", + method: "POST", + body: body, + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse session response") + } + + return SessionTokens( + accessToken: accessToken, + refreshToken: refreshToken + ) + } + + // MARK: - Helpers + + private func parsePagination(from json: [String: Any]) -> Pagination { + let pagination = json["pagination"] as? [String: Any] ?? [:] + return Pagination( + hasPreviousPage: pagination["has_previous_page"] as? Bool ?? false, + hasNextPage: pagination["has_next_page"] as? Bool ?? false, + startCursor: pagination["start_cursor"] as? String, + endCursor: pagination["end_cursor"] as? String + ) + } +} + +// MARK: - Supporting Types + +public struct PaginatedResult: Sendable { + public let items: [T] + public let pagination: Pagination +} + +public struct Pagination: Sendable { + public let hasPreviousPage: Bool + public let hasNextPage: Bool + public let startCursor: String? + public let endCursor: String? +} + +public struct SessionTokens: Sendable { + public let accessToken: String + public let refreshToken: String +} diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift new file mode 100644 index 0000000000..f3cbe5d3b6 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -0,0 +1,190 @@ +import Foundation +import Security + +/// Protocol for custom token storage implementations +public protocol TokenStoreProtocol: Sendable { + func getAccessToken() async -> String? + func getRefreshToken() async -> String? + func setTokens(accessToken: String?, refreshToken: String?) async + func clearTokens() async +} + +/// Token storage configuration +public enum TokenStore: Sendable { + /// Store tokens in Keychain (default, secure, persists across launches) + case keychain + + /// Store tokens in memory (lost on app restart) + case memory + + /// Explicit tokens (for server-side usage) + case explicit(accessToken: String, refreshToken: String) + + /// No token storage + case none + + /// Custom storage implementation + case custom(any TokenStoreProtocol) +} + +// MARK: - Keychain Token Store + +actor KeychainTokenStore: TokenStoreProtocol { + private let projectId: String + private let accessTokenKey: String + private let refreshTokenKey: String + + init(projectId: String) { + self.projectId = projectId + self.accessTokenKey = "stack-auth-access-\(projectId)" + self.refreshTokenKey = "stack-auth-refresh-\(projectId)" + } + + func getAccessToken() async -> String? { + return getKeychainItem(key: accessTokenKey) + } + + func getRefreshToken() async -> String? { + return getKeychainItem(key: refreshTokenKey) + } + + func setTokens(accessToken: String?, refreshToken: String?) async { + if let accessToken = accessToken { + setKeychainItem(key: accessTokenKey, value: accessToken) + } else { + deleteKeychainItem(key: accessTokenKey) + } + + if let refreshToken = refreshToken { + setKeychainItem(key: refreshTokenKey, value: refreshToken) + } else { + deleteKeychainItem(key: refreshTokenKey) + } + } + + func clearTokens() async { + deleteKeychainItem(key: accessTokenKey) + deleteKeychainItem(key: refreshTokenKey) + } + + // MARK: - Keychain Helpers + + private func getKeychainItem(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { + return nil + } + + return string + } + + private func setKeychainItem(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + + // First try to update + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary) + + if updateStatus == errSecItemNotFound { + // Item doesn't exist, add it + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + SecItemAdd(addQuery as CFDictionary, nil) + } + } + + private func deleteKeychainItem(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - Memory Token Store + +actor MemoryTokenStore: TokenStoreProtocol { + private var accessToken: String? + private var refreshToken: String? + + func getAccessToken() async -> String? { + return accessToken + } + + func getRefreshToken() async -> String? { + return refreshToken + } + + func setTokens(accessToken: String?, refreshToken: String?) async { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + func clearTokens() async { + self.accessToken = nil + self.refreshToken = nil + } +} + +// MARK: - Explicit Token Store + +actor ExplicitTokenStore: TokenStoreProtocol { + private let accessToken: String + private let refreshToken: String + + init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + func getAccessToken() async -> String? { + return accessToken + } + + func getRefreshToken() async -> String? { + return refreshToken + } + + func setTokens(accessToken: String?, refreshToken: String?) async { + // Explicit tokens are immutable + } + + func clearTokens() async { + // Explicit tokens are immutable + } +} + +// MARK: - Null Token Store + +actor NullTokenStore: TokenStoreProtocol { + func getAccessToken() async -> String? { nil } + func getRefreshToken() async -> String? { nil } + func setTokens(accessToken: String?, refreshToken: String?) async {} + func clearTokens() async {} +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift new file mode 100644 index 0000000000..5079e4db2b --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift @@ -0,0 +1,284 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Authentication Tests") +struct AuthenticationTests { + + // MARK: - Sign Up Tests + + @Test("Should sign up with valid credentials") + func signUpWithValidCredentials() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let primaryEmail = await user?.primaryEmail + #expect(primaryEmail == email) + } + + @Test("Should fail sign up with duplicate email") + func signUpWithDuplicateEmail() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + // First sign up + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + try await app.signOut() + + // Second sign up with same email should fail + do { + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + Issue.record("Expected UserWithEmailAlreadyExistsError") + } catch is UserWithEmailAlreadyExistsError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "USER_EMAIL_ALREADY_EXISTS" { + // Also acceptable + } + } + + @Test("Should fail sign up with weak password") + func signUpWithWeakPassword() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + do { + try await app.signUpWithCredential(email: email, password: TestConfig.weakPassword) + Issue.record("Expected password error") + } catch is PasswordRequirementsNotMetError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_REQUIREMENTS_NOT_MET" || error.code == "PASSWORD_TOO_SHORT" { + // Also acceptable - different error codes for password issues + } + } + + @Test("Should fail sign up with invalid email format") + func signUpWithInvalidEmail() async throws { + let app = TestConfig.createClientApp() + + do { + try await app.signUpWithCredential(email: "not-an-email", password: TestConfig.testPassword) + Issue.record("Expected error for invalid email") + } catch { + // Expected - any error is acceptable for invalid email + } + } + + // MARK: - Sign In Tests + + @Test("Should sign in with valid credentials") + func signInWithValidCredentials() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + // First sign up + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + try await app.signOut() + + // Then sign in + try await app.signInWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let userEmail = await user?.primaryEmail + #expect(userEmail == email) + } + + @Test("Should fail sign in with wrong password") + func signInWithWrongPassword() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + // First sign up + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + try await app.signOut() + + // Try sign in with wrong password + do { + try await app.signInWithCredential(email: email, password: "WrongPassword123!") + Issue.record("Expected EmailPasswordMismatchError") + } catch is EmailPasswordMismatchError { + // Expected + } + } + + @Test("Should fail sign in with non-existent user") + func signInWithNonExistentUser() async throws { + let app = TestConfig.createClientApp() + + do { + try await app.signInWithCredential(email: "nonexistent-\(UUID().uuidString)@example.com", password: TestConfig.testPassword) + Issue.record("Expected EmailPasswordMismatchError") + } catch is EmailPasswordMismatchError { + // Expected - returns same error as wrong password for security + } + } + + @Test("Should fail sign in with empty password") + func signInWithEmptyPassword() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + try await app.signOut() + + do { + try await app.signInWithCredential(email: email, password: "") + Issue.record("Expected error for empty password") + } catch { + // Expected - any error is acceptable for empty password + } + } + + // MARK: - Sign Out Tests + + @Test("Should sign out successfully") + func signOutSuccessfully() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let userBefore = try await app.getUser() + #expect(userBefore != nil) + + try await app.signOut() + + let userAfter = try await app.getUser() + #expect(userAfter == nil) + } + + @Test("Should be able to sign out when not signed in") + func signOutWhenNotSignedIn() async throws { + let app = TestConfig.createClientApp() + + // Should not throw even when not signed in + try await app.signOut() + + let user = try await app.getUser() + #expect(user == nil) + } + + @Test("Should clear tokens after sign out") + func clearTokensAfterSignOut() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let tokenBefore = await app.getAccessToken() + #expect(tokenBefore != nil) + + try await app.signOut() + + let tokenAfter = await app.getAccessToken() + #expect(tokenAfter == nil) + } + + // MARK: - Multiple Auth Cycles + + @Test("Should handle multiple sign in/out cycles") + func multipleAuthCycles() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + // Sign up + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + var user = try await app.getUser() + #expect(user != nil) + + // Sign out and in again (3 cycles) + for _ in 1...3 { + try await app.signOut() + user = try await app.getUser() + #expect(user == nil) + + try await app.signInWithCredential(email: email, password: TestConfig.testPassword) + user = try await app.getUser() + #expect(user != nil) + } + } + + // MARK: - Password Management + + @Test("Should update password for authenticated user") + func updatePassword() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + let newPassword = "NewPassword456!" + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + try await user?.updatePassword( + oldPassword: TestConfig.testPassword, + newPassword: newPassword + ) + + // Sign out and sign in with new password + try await app.signOut() + try await app.signInWithCredential(email: email, password: newPassword) + + let userAfter = try await app.getUser() + #expect(userAfter != nil) + } + + @Test("Should fail password update with wrong old password") + func updatePasswordWithWrongOldPassword() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + do { + try await user?.updatePassword( + oldPassword: "WrongOldPassword!", + newPassword: "NewPassword456!" + ) + Issue.record("Expected PasswordConfirmationMismatchError") + } catch is PasswordConfirmationMismatchError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_CONFIRMATION_MISMATCH" { + // Also acceptable + } + } + + // MARK: - Unauthenticated User Tests + + @Test("Should return nil for unauthenticated user") + func unauthenticatedUserReturnsNil() async throws { + let app = TestConfig.createClientApp() + + let user = try await app.getUser() + + #expect(user == nil) + } + + @Test("Should throw for unauthenticated user with or: throw") + func unauthenticatedUserThrows() async throws { + let app = TestConfig.createClientApp() + + await #expect(throws: UserNotSignedInError.self) { + _ = try await app.getUser(or: .throw) + } + } + + @Test("Should return nil for partial user when unauthenticated") + func unauthenticatedPartialUserReturnsNil() async throws { + let app = TestConfig.createClientApp() + + let partialUser = await app.getPartialUser() + + #expect(partialUser == nil) + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift new file mode 100644 index 0000000000..c67461c0e6 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift @@ -0,0 +1,182 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Contact Channel Tests") +struct ContactChannelTests { + + // MARK: - List Contact Channels Tests + + @Test("Should list contact channels after sign up") + func listContactChannelsAfterSignUp() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let channels = try await user?.listContactChannels() ?? [] + + // Should have at least the primary email + #expect(!channels.isEmpty) + + // Find the primary email channel + var primaryChannel: ContactChannel? = nil + for channel in channels { + let channelValue = await channel.value + let channelIsPrimary = await channel.isPrimary + if channelValue == email && channelIsPrimary { + primaryChannel = channel + break + } + } + #expect(primaryChannel != nil) + } + + @Test("Should have correct contact channel properties") + func contactChannelProperties() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let channels = try await user?.listContactChannels() ?? [] + + guard let channel = channels.first else { + Issue.record("Expected at least one contact channel") + return + } + + let channelId = channel.id // nonisolated, no await needed + let channelType = await channel.type + let channelValue = await channel.value + + #expect(!channelId.isEmpty) + #expect(channelType == "email") + #expect(!channelValue.isEmpty) + } + + @Test("Should identify primary contact channel") + func identifyPrimaryContactChannel() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let channels = try await user?.listContactChannels() ?? [] + + // Count primary channels + var primaryCount = 0 + var primaryValue: String? = nil + for channel in channels { + let isPrimary = await channel.isPrimary + if isPrimary { + primaryCount += 1 + primaryValue = await channel.value + } + } + + #expect(primaryCount == 1) + #expect(primaryValue == email) + } + + // MARK: - Contact Channel via Server + + @Test("Should list contact channels via server") + func listContactChannelsViaServer() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let channels = try await user.listContactChannels() + + #expect(!channels.isEmpty) + + // Find the email channel + var foundChannel: ContactChannel? = nil + for channel in channels { + let channelValue = await channel.value + if channelValue == email { + foundChannel = channel + break + } + } + #expect(foundChannel != nil) + + // Clean up + try await user.delete() + } + + @Test("Should handle user with no contact channels") + func userWithNoContactChannels() async throws { + let app = TestConfig.createServerApp() + + // Create user without email + let user = try await app.createUser(displayName: "No Email User") + + let channels = try await user.listContactChannels() + + // Should be empty + #expect(channels.isEmpty) + + // Clean up + try await user.delete() + } + + @Test("Should show verified status correctly") + func verifiedStatusCorrect() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + // Create user with verified email + let user = try await app.createUser(email: email, primaryEmailVerified: true) + + let channels = try await user.listContactChannels() + + // Find the email channel + var emailChannel: ContactChannel? = nil + for channel in channels { + let channelValue = await channel.value + if channelValue == email { + emailChannel = channel + break + } + } + + let isVerified = await emailChannel?.isVerified + #expect(isVerified == true) + + // Clean up + try await user.delete() + } + + @Test("Should show unverified status correctly") + func unverifiedStatusCorrect() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + // Create user with unverified email (default) + let user = try await app.createUser(email: email, primaryEmailVerified: false) + + let channels = try await user.listContactChannels() + + // Find the email channel + var emailChannel: ContactChannel? = nil + for channel in channels { + let channelValue = await channel.value + if channelValue == email { + emailChannel = channel + break + } + } + + let isVerified = await emailChannel?.isVerified + #expect(isVerified == false) + + // Clean up + try await user.delete() + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift new file mode 100644 index 0000000000..a096a64c5b --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift @@ -0,0 +1,248 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Error Handling Tests") +struct ErrorHandlingTests { + + // MARK: - Authentication Errors + + @Test("Should throw EmailPasswordMismatchError for wrong credentials") + func emailPasswordMismatchError() async throws { + let app = TestConfig.createClientApp() + + do { + try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong") + Issue.record("Expected EmailPasswordMismatchError") + } catch is EmailPasswordMismatchError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "EMAIL_PASSWORD_MISMATCH" { + // Also acceptable + } + } + + @Test("Should throw UserWithEmailAlreadyExistsError for duplicate sign up") + func userAlreadyExistsError() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + try await app.signOut() + + do { + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + Issue.record("Expected UserWithEmailAlreadyExistsError") + } catch is UserWithEmailAlreadyExistsError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "USER_EMAIL_ALREADY_EXISTS" { + // Also acceptable + } + } + + @Test("Should throw UserNotSignedInError for unauthenticated access") + func userNotSignedInError() async throws { + let app = TestConfig.createClientApp() + + await #expect(throws: UserNotSignedInError.self) { + _ = try await app.getUser(or: .throw) + } + } + + // MARK: - Error Properties + + @Test("Should include error code in error") + func errorIncludesCode() async throws { + let app = TestConfig.createClientApp() + + do { + try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong") + Issue.record("Expected error") + } catch let error as StackAuthErrorProtocol { + #expect(!error.code.isEmpty) + #expect(error.code == "EMAIL_PASSWORD_MISMATCH") + } + } + + @Test("Should include error message in error") + func errorIncludesMessage() async throws { + let app = TestConfig.createClientApp() + + do { + try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong") + Issue.record("Expected error") + } catch let error as StackAuthErrorProtocol { + #expect(!error.message.isEmpty) + } + } + + @Test("Should have meaningful error description") + func errorHasMeaningfulDescription() async throws { + let app = TestConfig.createClientApp() + + do { + try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong") + Issue.record("Expected error") + } catch let error as StackAuthErrorProtocol { + let description = error.description + #expect(!description.isEmpty) + #expect(description.contains("EMAIL_PASSWORD_MISMATCH") || description.contains("password")) + } + } + + // MARK: - Error Type Matching + + @Test("Should match StackAuthError for unknown error codes") + func unknownErrorCodeMatchesStackAuthError() async throws { + // Create a StackAuthError with unknown code + let error = StackAuthError(code: "UNKNOWN_ERROR_CODE", message: "Test error") + + #expect(error.code == "UNKNOWN_ERROR_CODE") + #expect(error.message == "Test error") + } + + @Test("Should properly identify specific error types") + func identifySpecificErrorTypes() async throws { + let emailError = EmailPasswordMismatchError() + let userExistsError = UserWithEmailAlreadyExistsError() + let notSignedInError = UserNotSignedInError() + + #expect(emailError.code == "EMAIL_PASSWORD_MISMATCH") + #expect(userExistsError.code == "USER_EMAIL_ALREADY_EXISTS") + #expect(notSignedInError.code == "USER_NOT_SIGNED_IN") + } + + // MARK: - Error Recovery + + @Test("Should be able to retry after authentication error") + func retryAfterAuthError() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + // Sign up + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + try await app.signOut() + + // First try with wrong password + do { + try await app.signInWithCredential(email: email, password: "WrongPassword123!") + } catch is EmailPasswordMismatchError { + // Expected + } + + // Should still be able to sign in with correct password + try await app.signInWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + } + + // MARK: - Server-Side Errors + + @Test("Should handle user not found for server operations") + func serverUserNotFound() async throws { + let app = TestConfig.createServerApp() + + let fakeUserId = UUID().uuidString + let user = try await app.getUser(id: fakeUserId) + + // Should return nil, not throw + #expect(user == nil) + } + + @Test("Should handle team not found for server operations") + func serverTeamNotFound() async throws { + let app = TestConfig.createServerApp() + + let fakeTeamId = UUID().uuidString + let team = try await app.getTeam(id: fakeTeamId) + + // Should return nil, not throw + #expect(team == nil) + } + + // MARK: - Password Errors + + @Test("Should throw for weak password") + func weakPasswordError() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + do { + try await app.signUpWithCredential(email: email, password: "123") + Issue.record("Expected password error") + } catch is PasswordRequirementsNotMetError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_REQUIREMENTS_NOT_MET" || error.code == "PASSWORD_TOO_SHORT" { + // Also acceptable - different error codes for password issues + } + } + + @Test("Should throw PasswordConfirmationMismatchError for wrong old password") + func wrongOldPasswordError() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + + do { + try await user?.updatePassword(oldPassword: "WrongOld123!", newPassword: "NewPass456!") + Issue.record("Expected PasswordConfirmationMismatchError") + } catch is PasswordConfirmationMismatchError { + // Expected + } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_CONFIRMATION_MISMATCH" { + // Also acceptable + } + } +} + +@Suite("Project Tests") +struct ProjectTests { + + // MARK: - Project Info Tests + + @Test("Should get project info via client") + func getProjectViaClient() async throws { + let app = TestConfig.createClientApp() + + let project = try await app.getProject() + + #expect(project.id == testProjectId) + } + + @Test("Should get project info via server") + func getProjectViaServer() async throws { + let app = TestConfig.createServerApp() + + let project = try await app.getProject() + + #expect(project.id == testProjectId) + } + + @Test("Should access project config") + func accessProjectConfig() async throws { + let app = TestConfig.createClientApp() + + let project = try await app.getProject() + + // Config should exist (even if empty) + let _ = project.config + } + + @Test("Should create client app with correct project ID") + func createClientAppWithProjectId() async throws { + let app = TestConfig.createClientApp() + + let projectId = await app.projectId + #expect(projectId == testProjectId) + } + + @Test("Should create server app with correct project ID") + func createServerAppWithProjectId() async throws { + let app = TestConfig.createServerApp() + + let projectId = await app.projectId + #expect(projectId == testProjectId) + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift new file mode 100644 index 0000000000..e95937ecc5 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift @@ -0,0 +1,130 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("OAuth Tests") +struct OAuthTests { + + // MARK: - OAuth URL Generation Tests + + @Test("Should generate OAuth URL for Google") + func generateOAuthUrlForGoogle() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google") + + #expect(result.url.absoluteString.contains("oauth/authorize/google")) + #expect(!result.state.isEmpty) + #expect(!result.codeVerifier.isEmpty) + } + + @Test("Should generate OAuth URL for GitHub") + func generateOAuthUrlForGitHub() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "github") + + #expect(result.url.absoluteString.contains("oauth/authorize/github")) + #expect(!result.state.isEmpty) + #expect(!result.codeVerifier.isEmpty) + } + + @Test("Should generate OAuth URL for Microsoft") + func generateOAuthUrlForMicrosoft() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "microsoft") + + #expect(result.url.absoluteString.contains("oauth/authorize/microsoft")) + #expect(!result.state.isEmpty) + #expect(!result.codeVerifier.isEmpty) + } + + @Test("Should include project ID in OAuth URL") + func oauthUrlIncludesProjectId() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google") + + #expect(result.url.absoluteString.contains("client_id=\(testProjectId)")) + } + + @Test("Should include state in OAuth URL") + func oauthUrlIncludesState() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google") + + // URL should contain the state parameter + #expect(result.url.absoluteString.contains("state=")) + } + + @Test("Should generate PKCE code verifier") + func generatesPkceCodeVerifier() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google") + + // Code verifier should be long enough for security (43-128 chars for PKCE) + #expect(result.codeVerifier.count >= 43) + } + + @Test("Should generate unique state for each call") + func generatesUniqueState() async throws { + let app = TestConfig.createClientApp() + + let result1 = try await app.getOAuthUrl(provider: "google") + let result2 = try await app.getOAuthUrl(provider: "google") + + #expect(result1.state != result2.state) + } + + @Test("Should generate unique code verifier for each call") + func generatesUniqueCodeVerifier() async throws { + let app = TestConfig.createClientApp() + + let result1 = try await app.getOAuthUrl(provider: "google") + let result2 = try await app.getOAuthUrl(provider: "google") + + #expect(result1.codeVerifier != result2.codeVerifier) + } + + @Test("Should handle case-insensitive provider name") + func caseInsensitiveProvider() async throws { + let app = TestConfig.createClientApp() + + let result1 = try await app.getOAuthUrl(provider: "Google") + let result2 = try await app.getOAuthUrl(provider: "GOOGLE") + let result3 = try await app.getOAuthUrl(provider: "google") + + // All should generate valid URLs with google provider + #expect(result1.url.absoluteString.contains("oauth/authorize/google")) + #expect(result2.url.absoluteString.contains("oauth/authorize/google")) + #expect(result3.url.absoluteString.contains("oauth/authorize/google")) + } + + @Test("Should include code challenge in URL") + func includesCodeChallenge() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google") + + // URL should contain PKCE code challenge + #expect(result.url.absoluteString.contains("code_challenge=")) + #expect(result.url.absoluteString.contains("code_challenge_method=S256")) + } + + // MARK: - OAuth URL with Custom Options + + @Test("Should include custom redirect URL") + func customRedirectUrl() async throws { + let app = TestConfig.createClientApp() + let customRedirect = "https://myapp.com/oauth/callback" + + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: customRedirect) + + // URL should contain the encoded redirect URL + let encodedRedirect = customRedirect.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? customRedirect + #expect(result.url.absoluteString.contains(encodedRedirect) || result.url.absoluteString.contains("redirect_uri=")) + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift new file mode 100644 index 0000000000..978ca2b4ec --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift @@ -0,0 +1,457 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Team Tests - Client") +struct ClientTeamTests { + + // MARK: - Team Creation Tests + + @Test("Should create team with display name") + func createTeamWithDisplayName() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let teamName = TestConfig.uniqueTeamName() + let team = try await user?.createTeam(displayName: teamName) + + #expect(team != nil) + + let displayName = await team?.displayName + #expect(displayName == teamName) + } + + @Test("Should create team with metadata") + func createTeamWithMetadata() async throws { + // Use server app for full control over team creation + let serverApp = TestConfig.createServerApp() + let teamName = TestConfig.uniqueTeamName() + + let team = try await serverApp.createTeam( + displayName: teamName, + clientMetadata: ["type": "test"] + ) + + let clientMetadata: [String: Any] = await team.clientMetadata + let typeValue = clientMetadata["type"] as? String + #expect(typeValue == "test") + + // Clean up + try await team.delete() + } + + @Test("Should add creator to team on creation") + func creatorAddedToTeam() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let userId = await user?.id + + let team = try await user?.createTeam(displayName: TestConfig.uniqueTeamName()) + + // List team users and verify creator is included + let teamUsers = try await team?.listUsers() ?? [] + let creatorFound = teamUsers.contains { $0.id == userId } + #expect(creatorFound) + } + + // MARK: - Team Listing Tests + + @Test("Should list user's teams") + func listUserTeams() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + + // Create multiple teams + let team1 = try await user?.createTeam(displayName: "Team 1 \(UUID().uuidString.prefix(4))") + let team2 = try await user?.createTeam(displayName: "Team 2 \(UUID().uuidString.prefix(4))") + + let teams = try await user?.listTeams() ?? [] + + #expect(teams.count >= 2) + #expect(teams.contains { $0.id == team1?.id }) + #expect(teams.contains { $0.id == team2?.id }) + } + + @Test("Should get team by ID") + func getTeamById() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let teamName = TestConfig.uniqueTeamName() + let createdTeam = try await user?.createTeam(displayName: teamName) + let teamId = createdTeam?.id + + #expect(teamId != nil) + + let fetchedTeam = try await user?.getTeam(id: teamId!) + + #expect(fetchedTeam != nil) + + let fetchedName = await fetchedTeam?.displayName + #expect(fetchedName == teamName) + } + + @Test("Should return nil for non-member team") + func getNonMemberTeam() async throws { + let serverApp = TestConfig.createServerApp() + + // Create a team via server (user not a member) + let team = try await serverApp.createTeam(displayName: TestConfig.uniqueTeamName()) + let teamId = team.id + + // Try to get it as a different user + let clientApp = TestConfig.createClientApp() + try await clientApp.signUpWithCredential(email: TestConfig.uniqueEmail(), password: TestConfig.testPassword) + + let user = try await clientApp.getUser() + let fetchedTeam = try await user?.getTeam(id: teamId) + + // Should be nil since user is not a member + #expect(fetchedTeam == nil) + + // Clean up + try await team.delete() + } + + // MARK: - Team Update Tests + + @Test("Should update team display name") + func updateTeamDisplayName() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let team = try await user?.createTeam(displayName: "Original Name") + + let newName = "Updated Name \(UUID().uuidString.prefix(8))" + try await team?.update(displayName: newName) + + let displayName = await team?.displayName + #expect(displayName == newName) + } + + @Test("Should update team profile image") + func updateTeamProfileImage() async throws { + // Use server app for updating team properties to avoid permission issues + let serverApp = TestConfig.createServerApp() + + let team = try await serverApp.createTeam(displayName: TestConfig.uniqueTeamName()) + + let newImageUrl = "https://example.com/new-image.png" + try await team.update(profileImageUrl: newImageUrl) + + let profileImageUrl = await team.profileImageUrl + #expect(profileImageUrl == newImageUrl) + + // Clean up + try await team.delete() + } + + @Test("Should update team client metadata") + func updateTeamClientMetadata() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let team = try await user?.createTeam(displayName: TestConfig.uniqueTeamName()) + + try await team?.update(clientMetadata: ["plan": "pro", "seats": 10]) + + let clientMetadata: [String: Any]? = await team?.clientMetadata + let planValue = clientMetadata?["plan"] as? String + let seatsValue = clientMetadata?["seats"] as? Int + #expect(planValue == "pro") + #expect(seatsValue == 10) + } + + // MARK: - Team Deletion Tests + // Note: Client-side team deletion requires specific permissions + // These tests are covered in the server-side team tests instead + + // MARK: - Team Members Tests + + @Test("Should list team members") + func listTeamMembers() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + let team = try await user?.createTeam(displayName: TestConfig.uniqueTeamName()) + + let members = try await team?.listUsers() ?? [] + + // Should have at least the creator + #expect(!members.isEmpty) + } +} + +@Suite("Team Tests - Server") +struct ServerTeamTests { + + // MARK: - Team Creation Tests + + @Test("Should create team with server app") + func createTeamWithServer() async throws { + let app = TestConfig.createServerApp() + let teamName = TestConfig.uniqueTeamName() + + let team = try await app.createTeam(displayName: teamName) + + let displayName = await team.displayName + #expect(displayName == teamName) + + // Clean up + try await team.delete() + } + + @Test("Should create team with creator user") + func createTeamWithCreator() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + let userId = user.id + + let team = try await app.createTeam( + displayName: TestConfig.uniqueTeamName(), + creatorUserId: userId + ) + + // Verify user is in team + let teamUsers = try await team.listUsers() + let found = teamUsers.contains { $0.id == userId } + #expect(found) + + // Clean up + try await team.delete() + try await user.delete() + } + + @Test("Should create team with all options") + func createTeamWithAllOptions() async throws { + let app = TestConfig.createServerApp() + + let team = try await app.createTeam( + displayName: TestConfig.uniqueTeamName(), + profileImageUrl: "https://example.com/image.png", + clientMetadata: ["tier": "enterprise"], + serverMetadata: ["billing_id": "bill_123"] + ) + + let profileImageUrl = await team.profileImageUrl + let clientMeta = await team.clientMetadata + let serverMeta = await team.serverMetadata + + #expect(profileImageUrl == "https://example.com/image.png") + #expect(clientMeta["tier"] as? String == "enterprise") + #expect(serverMeta["billing_id"] as? String == "bill_123") + + // Clean up + try await team.delete() + } + + // MARK: - Team Listing Tests + + @Test("Should list all teams") + func listAllTeams() async throws { + let app = TestConfig.createServerApp() + + let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName()) + + let teams = try await app.listTeams() + + let found = teams.contains { $0.id == team.id } + #expect(found) + + // Clean up + try await team.delete() + } + + @Test("Should list teams for specific user") + func listTeamsForUser() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + let userId = user.id + + // Create team with user as member + let team = try await app.createTeam( + displayName: TestConfig.uniqueTeamName(), + creatorUserId: userId + ) + + // List teams for this user + let teams = try await app.listTeams(userId: userId) + + let found = teams.contains { $0.id == team.id } + #expect(found) + + // Clean up + try await team.delete() + try await user.delete() + } + + @Test("Should get team by ID") + func getTeamById() async throws { + let app = TestConfig.createServerApp() + let teamName = TestConfig.uniqueTeamName() + + let createdTeam = try await app.createTeam(displayName: teamName) + let teamId = createdTeam.id + + let fetchedTeam = try await app.getTeam(id: teamId) + + #expect(fetchedTeam != nil) + + let fetchedName = await fetchedTeam?.displayName + #expect(fetchedName == teamName) + + // Clean up + try await createdTeam.delete() + } + + @Test("Should return nil for non-existent team") + func getNonExistentTeam() async throws { + let app = TestConfig.createServerApp() + + let fakeTeamId = UUID().uuidString + let team = try await app.getTeam(id: fakeTeamId) + + #expect(team == nil) + } + + // MARK: - Team Update Tests + + @Test("Should update team via server") + func updateTeamViaServer() async throws { + let app = TestConfig.createServerApp() + + let team = try await app.createTeam(displayName: "Original") + + try await team.update( + displayName: "Updated", + serverMetadata: ["status": "active"] + ) + + let displayName = await team.displayName + let serverMeta = await team.serverMetadata + + #expect(displayName == "Updated") + #expect(serverMeta["status"] as? String == "active") + + // Clean up + try await team.delete() + } + + // MARK: - Team Membership Tests + + @Test("Should add user to team") + func addUserToTeam() async throws { + let app = TestConfig.createServerApp() + + let user = try await app.createUser(email: TestConfig.uniqueEmail()) + let userId = user.id + + let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName()) + + try await team.addUser(id: userId) + + let teamUsers = try await team.listUsers() + let found = teamUsers.contains { $0.id == userId } + #expect(found) + + // Clean up + try await team.delete() + try await user.delete() + } + + @Test("Should remove user from team") + func removeUserFromTeam() async throws { + let app = TestConfig.createServerApp() + + let user = try await app.createUser(email: TestConfig.uniqueEmail()) + let userId = user.id + + let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName()) + + // Add user + try await team.addUser(id: userId) + + var teamUsers = try await team.listUsers() + var found = teamUsers.contains { $0.id == userId } + #expect(found) + + // Remove user + try await team.removeUser(id: userId) + + teamUsers = try await team.listUsers() + found = teamUsers.contains { $0.id == userId } + #expect(!found) + + // Clean up + try await team.delete() + try await user.delete() + } + + @Test("Should list team users") + func listTeamUsers() async throws { + let app = TestConfig.createServerApp() + + let user1 = try await app.createUser(email: TestConfig.uniqueEmail()) + let user2 = try await app.createUser(email: TestConfig.uniqueEmail()) + + let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName()) + + try await team.addUser(id: user1.id) + try await team.addUser(id: user2.id) + + let teamUsers = try await team.listUsers() + + #expect(teamUsers.count >= 2) + #expect(teamUsers.contains { $0.id == user1.id }) + #expect(teamUsers.contains { $0.id == user2.id }) + + // Clean up + try await team.delete() + try await user1.delete() + try await user2.delete() + } + + // MARK: - Team Deletion Tests + + @Test("Should delete team via server") + func deleteTeamViaServer() async throws { + let app = TestConfig.createServerApp() + + let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName()) + let teamId = team.id + + try await team.delete() + + let deletedTeam = try await app.getTeam(id: teamId) + #expect(deletedTeam == nil) + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift new file mode 100644 index 0000000000..168fa14c28 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -0,0 +1,79 @@ +import Foundation +@testable import StackAuth + +/// Shared test configuration +/// Set environment variables to customize test behavior: +/// - NEXT_PUBLIC_STACK_PORT_PREFIX: Port prefix for backend (default: "81") +/// - STACK_SKIP_E2E_TESTS: Set to "true" to skip E2E tests +struct TestConfig { + static let portPrefix = ProcessInfo.processInfo.environment["NEXT_PUBLIC_STACK_PORT_PREFIX"] ?? "81" + static let baseUrl = "http://localhost:\(portPrefix)02" + static let skipE2E = ProcessInfo.processInfo.environment["STACK_SKIP_E2E_TESTS"] == "true" + + // Test credentials - these should match the test project in the backend + // See apps/e2e/.env.development for the source of truth + static let projectId = "internal" + static let publishableClientKey = "this-publishable-client-key-is-for-local-development-only" + static let secretServerKey = "this-secret-server-key-is-for-local-development-only" + + /// Check if backend is accessible + static func isBackendAvailable() async -> Bool { + guard !skipE2E else { return false } + + guard let url = URL(string: "\(baseUrl)/api/v1/health") else { return false } + + do { + let (_, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode < 500 + } + return false + } catch { + return false + } + } + + /// Generate a unique test email + static func uniqueEmail() -> String { + "test-\(UUID().uuidString.lowercased())@example.com" + } + + /// Generate a unique team name + static func uniqueTeamName() -> String { + "Test Team \(UUID().uuidString.prefix(8))" + } + + /// Create a new client app instance for testing + static func createClientApp(tokenStore: TokenStore = .memory) -> StackClientApp { + StackClientApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + baseUrl: baseUrl, + tokenStore: tokenStore, + noAutomaticPrefetch: true + ) + } + + /// Create a new server app instance for testing + static func createServerApp() -> StackServerApp { + StackServerApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + secretServerKey: secretServerKey, + baseUrl: baseUrl + ) + } + + /// Standard test password that meets requirements + static let testPassword = "TestPassword123!" + + /// Weak password that should be rejected + static let weakPassword = "123" +} + +// MARK: - Convenience Aliases + +let baseUrl = TestConfig.baseUrl +let testProjectId = TestConfig.projectId +let testPublishableClientKey = TestConfig.publishableClientKey +let testSecretServerKey = TestConfig.secretServerKey diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift new file mode 100644 index 0000000000..d7fdf7ee07 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift @@ -0,0 +1,239 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Token Storage Tests") +struct TokenStorageTests { + + // MARK: - Memory Token Store Tests + + @Test("Should store tokens in memory") + func memoryTokenStore() async throws { + let app = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let accessToken = await app.getAccessToken() + let refreshToken = await app.getRefreshToken() + + #expect(accessToken != nil) + #expect(refreshToken != nil) + #expect(!accessToken!.isEmpty) + #expect(!refreshToken!.isEmpty) + } + + @Test("Should clear memory tokens on sign out") + func memoryTokensClearedOnSignOut() async throws { + let app = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let tokenBefore = await app.getAccessToken() + #expect(tokenBefore != nil) + + try await app.signOut() + + let tokenAfter = await app.getAccessToken() + #expect(tokenAfter == nil) + } + + // MARK: - Explicit Token Store Tests + + @Test("Should use explicitly provided tokens") + func explicitTokenStore() async throws { + // First, get real tokens + let app1 = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() + + #expect(accessToken != nil) + #expect(refreshToken != nil) + + // Now use explicit store with those tokens + let app2 = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .explicit(accessToken: accessToken!, refreshToken: refreshToken!), + noAutomaticPrefetch: true + ) + + let user = try await app2.getUser() + #expect(user != nil) + + let userEmail = await user?.primaryEmail + #expect(userEmail == email) + } + + @Test("Should work with both tokens provided") + func explicitBothTokens() async throws { + // Get real tokens + let app1 = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() + #expect(accessToken != nil) + #expect(refreshToken != nil) + + // Use both tokens + let app2 = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .explicit(accessToken: accessToken!, refreshToken: refreshToken!), + noAutomaticPrefetch: true + ) + + // Should work for requests + let user = try await app2.getUser() + #expect(user != nil) + } + + // MARK: - Token Format Tests + + @Test("Should return JWT format access token") + func accessTokenIsJwt() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let accessToken = await app.getAccessToken() + #expect(accessToken != nil) + + // JWT has three parts separated by dots + let parts = accessToken!.split(separator: ".") + #expect(parts.count == 3) + } + + @Test("Should return refresh token in correct format") + func refreshTokenFormat() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let refreshToken = await app.getRefreshToken() + #expect(refreshToken != nil) + #expect(!refreshToken!.isEmpty) + // Refresh token should be a reasonable length + #expect(refreshToken!.count > 10) + } + + // MARK: - Auth Headers Tests + + @Test("Should generate auth headers with token") + func authHeadersWithToken() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let headers = await app.getAuthHeaders() + + #expect(headers["x-stack-auth"] != nil) + #expect(!headers["x-stack-auth"]!.isEmpty) + } + + @Test("Should generate consistent auth headers format") + func authHeadersFormat() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let headers = await app.getAuthHeaders() + + // When authenticated, x-stack-auth should be present and contain the token + let authHeader = headers["x-stack-auth"] + #expect(authHeader != nil) + #expect(!authHeader!.isEmpty) + } + + // MARK: - Partial User from Token Tests + + @Test("Should get partial user from token without API call") + func partialUserFromToken() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let partialUser = await app.getPartialUser() + + #expect(partialUser != nil) + #expect(partialUser?.id != nil) + #expect(partialUser?.primaryEmail == email) + } + + @Test("Should return nil partial user when not authenticated") + func partialUserWhenNotAuthenticated() async throws { + let app = TestConfig.createClientApp() + + let partialUser = await app.getPartialUser() + + #expect(partialUser == nil) + } + + // MARK: - Token Persistence Between Apps + + @Test("Should share tokens between app instances with same store") + func shareTokensBetweenApps() async throws { + // Get tokens from first app + let app1 = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() + + // Create second app with explicit tokens + let app2 = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .explicit(accessToken: accessToken!, refreshToken: refreshToken!), + noAutomaticPrefetch: true + ) + + // Both should have same user + let user1 = try await app1.getUser() + let user2 = try await app2.getUser() + + let id1 = await user1?.id + let id2 = await user2?.id + + #expect(id1 == id2) + } + + // MARK: - Null Token Store Tests + + @Test("Should work with null token store for anonymous requests") + func nullTokenStore() async throws { + let app = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .none, + noAutomaticPrefetch: true + ) + + // Should be able to get project without authentication + let project = try await app.getProject() + #expect(project.id == testProjectId) + + // User should be nil + let user = try await app.getUser() + #expect(user == nil) + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift new file mode 100644 index 0000000000..c44a23343f --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift @@ -0,0 +1,415 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("User Management Tests - Client") +struct ClientUserTests { + + // MARK: - User Profile Tests + + @Test("Should get user properties after sign up") + func getUserProperties() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let id = await user?.id + let primaryEmail = await user?.primaryEmail + let displayName = await user?.displayName + + #expect(id != nil) + #expect(!id!.isEmpty) + #expect(primaryEmail == email) + #expect(displayName == nil) // Not set yet + } + + @Test("Should update display name") + func updateDisplayName() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let newName = "Test User \(UUID().uuidString.prefix(8))" + try await user?.setDisplayName(newName) + + let displayName = await user?.displayName + #expect(displayName == newName) + } + + @Test("Should update display name multiple times") + func updateDisplayNameMultipleTimes() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + + // First set a name + try await user?.setDisplayName("First Name") + var displayName = await user?.displayName + #expect(displayName == "First Name") + + // Then change it + try await user?.setDisplayName("Second Name") + displayName = await user?.displayName + #expect(displayName == "Second Name") + } + + @Test("Should update client metadata") + func updateClientMetadata() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let metadata: [String: Any] = [ + "theme": "dark", + "language": "en", + "notifications": true, + "count": 42 + ] + try await user?.update(clientMetadata: metadata) + + let clientMetadata = await user?.clientMetadata + #expect(clientMetadata?["theme"] as? String == "dark") + #expect(clientMetadata?["language"] as? String == "en") + #expect(clientMetadata?["notifications"] as? Bool == true) + #expect(clientMetadata?["count"] as? Int == 42) + } + + @Test("Should get partial user from token") + func getPartialUser() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let partialUser = await app.getPartialUser() + #expect(partialUser != nil) + #expect(partialUser?.primaryEmail == email) + #expect(partialUser?.id != nil) + } + + @Test("Should get access token after authentication") + func getAccessToken() async throws { + let app = TestConfig.createClientApp() + + // No token before sign in + let tokenBefore = await app.getAccessToken() + #expect(tokenBefore == nil) + + let email = TestConfig.uniqueEmail() + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + // Token after sign in + let tokenAfter = await app.getAccessToken() + #expect(tokenAfter != nil) + #expect(!tokenAfter!.isEmpty) + } + + @Test("Should get auth headers for API calls") + func getAuthHeaders() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let headers = await app.getAuthHeaders() + #expect(headers["x-stack-auth"] != nil) + #expect(!headers["x-stack-auth"]!.isEmpty) + } +} + +@Suite("User Management Tests - Server") +struct ServerUserTests { + + // MARK: - User Creation Tests + + @Test("Should create user with email only") + func createUserWithEmailOnly() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let primaryEmail = await user.primaryEmail + #expect(primaryEmail == email) + + // Clean up + try await user.delete() + } + + @Test("Should create user with all options") + func createUserWithAllOptions() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + let displayName = "Full User \(UUID().uuidString.prefix(8))" + + let user = try await app.createUser( + email: email, + password: TestConfig.testPassword, + displayName: displayName, + primaryEmailVerified: true, + clientMetadata: ["role": "admin"], + serverMetadata: ["internal_id": "12345"] + ) + + let userEmail = await user.primaryEmail + let userName = await user.displayName + let clientMeta = await user.clientMetadata + let serverMeta = await user.serverMetadata + + #expect(userEmail == email) + #expect(userName == displayName) + #expect(clientMeta["role"] as? String == "admin") + #expect(serverMeta["internal_id"] as? String == "12345") + + // Clean up + try await user.delete() + } + + @Test("Should create user without email") + func createUserWithoutEmail() async throws { + let app = TestConfig.createServerApp() + + let user = try await app.createUser(displayName: "No Email User") + + let primaryEmail = await user.primaryEmail + let displayName = await user.displayName + + #expect(primaryEmail == nil) + #expect(displayName == "No Email User") + + // Clean up + try await user.delete() + } + + // MARK: - User Retrieval Tests + + @Test("Should list users with pagination") + func listUsersWithPagination() async throws { + let app = TestConfig.createServerApp() + + // Create a few users + var createdUsers: [ServerUser] = [] + for _ in 0..<3 { + let user = try await app.createUser(email: TestConfig.uniqueEmail()) + createdUsers.append(user) + } + + // List with limit + let result = try await app.listUsers(limit: 2) + #expect(!result.items.isEmpty) + #expect(result.items.count <= 2) + + // Clean up + for user in createdUsers { + try await user.delete() + } + } + + @Test("Should get user by ID") + func getUserById() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let createdUser = try await app.createUser(email: email) + let userId = createdUser.id + + let fetchedUser = try await app.getUser(id: userId) + + #expect(fetchedUser != nil) + + let fetchedEmail = await fetchedUser?.primaryEmail + #expect(fetchedEmail == email) + + // Clean up + try await createdUser.delete() + } + + @Test("Should return nil for non-existent user") + func getNonExistentUser() async throws { + let app = TestConfig.createServerApp() + + let fakeUserId = UUID().uuidString + let user = try await app.getUser(id: fakeUserId) + + #expect(user == nil) + } + + // MARK: - User Update Tests + + @Test("Should update user display name") + func updateUserDisplayName() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let newName = "Updated Name \(UUID().uuidString.prefix(8))" + try await user.update(displayName: newName) + + let displayName = await user.displayName + #expect(displayName == newName) + + // Clean up + try await user.delete() + } + + @Test("Should update server metadata") + func updateServerMetadata() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let metadata: [String: Any] = [ + "internalKey": "internalValue", + "score": 100, + "verified": true + ] + try await user.update(serverMetadata: metadata) + + let serverMeta = await user.serverMetadata + #expect(serverMeta["internalKey"] as? String == "internalValue") + #expect(serverMeta["score"] as? Int == 100) + #expect(serverMeta["verified"] as? Bool == true) + + // Clean up + try await user.delete() + } + + @Test("Should update client metadata via server") + func updateClientMetadataViaServer() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + try await user.update(clientMetadata: ["preference": "light"]) + + let clientMeta = await user.clientMetadata + #expect(clientMeta["preference"] as? String == "light") + + // Clean up + try await user.delete() + } + + @Test("Should update multiple fields at once") + func updateMultipleFields() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + try await user.update( + displayName: "Multi Update User", + clientMetadata: ["key": "value"], + serverMetadata: ["serverKey": "serverValue"] + ) + + let displayName = await user.displayName + let clientMeta = await user.clientMetadata + let serverMeta = await user.serverMetadata + + #expect(displayName == "Multi Update User") + #expect(clientMeta["key"] as? String == "value") + #expect(serverMeta["serverKey"] as? String == "serverValue") + + // Clean up + try await user.delete() + } + + // MARK: - Password Management + + @Test("Should create user with password and sign in") + func createUserWithPasswordAndSignIn() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + // Create user with password + let user = try await app.createUser( + email: email, + password: TestConfig.testPassword, + primaryEmailAuthEnabled: true + ) + + // Verify can sign in with password + let clientApp = TestConfig.createClientApp() + try await clientApp.signInWithCredential(email: email, password: TestConfig.testPassword) + + let signedInUser = try await clientApp.getUser() + #expect(signedInUser != nil) + + // Clean up + try await user.delete() + } + + // MARK: - User Deletion Tests + + @Test("Should delete user") + func deleteUser() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + let userId = user.id + + // Verify user exists + let fetchedUser = try await app.getUser(id: userId) + #expect(fetchedUser != nil) + + // Delete user + try await user.delete() + + // Verify user is deleted + let deletedUser = try await app.getUser(id: userId) + #expect(deletedUser == nil) + } + + // MARK: - Session/Impersonation Tests + + @Test("Should create session for impersonation") + func createSession() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + let userId = user.id + + let tokens = try await app.createSession(userId: userId) + + #expect(!tokens.accessToken.isEmpty) + #expect(!tokens.refreshToken.isEmpty) + + // Verify the tokens work + let clientApp = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .explicit(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken), + noAutomaticPrefetch: true + ) + + let currentUser = try await clientApp.getUser() + #expect(currentUser != nil) + + let currentUserId = await currentUser?.id + #expect(currentUserId == userId) + + // Clean up + try await user.delete() + } +} diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 47152ab71f..da5aaf860d 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -91,6 +91,14 @@ For rate limiting (429 response): 3. If no Retry-After header: retry immediately with backoff +### Request Body + +POST, PATCH, and PUT requests MUST include a JSON body, even if empty. +If no body data is needed, send an empty object: {} + +Set Content-Type: application/json for all requests with a body. + + ### Response Processing 1. Check x-stack-actual-status header for real status code @@ -129,10 +137,16 @@ See packages/stack-shared/src/known-errors.ts for all error types. The base error type for all Stack Auth API errors. Properties: - code: string - error code from API (e.g., "user_not_found") + code: string - error code from API, UPPERCASE_WITH_UNDERSCORES (e.g., "USER_NOT_FOUND") message: string - human-readable error message details: object? - optional additional details +Error codes are always UPPERCASE_WITH_UNDERSCORES format. +Examples: EMAIL_PASSWORD_MISMATCH, USER_NOT_FOUND, PASSWORD_REQUIREMENTS_NOT_MET, PASSWORD_TOO_SHORT + +Note: PASSWORD_TOO_SHORT is returned when a password doesn't meet minimum length requirements. +PASSWORD_REQUIREMENTS_NOT_MET is a more general error for other password policy violations. + All function-specific errors (like PasswordResetCodeInvalid, EmailPasswordMismatch, etc.) should extend or be instances of StackAuthApiError. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 9a590851cd..94acd96aa0 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -115,6 +115,36 @@ Call callOAuthCallback() on the callback page/handler to complete the flow. Does not error (redirects before any error can occur). +## getOAuthUrl(provider, options?) + +Returns the OAuth authorization URL without performing the redirect. +Useful for non-browser environments or custom OAuth handling. + +Arguments: + provider: string - OAuth provider ID (e.g., "google", "github", "microsoft") + options.redirectUrl: string? - custom callback URL (default: urls.oauthCallback) + options.state: string? - custom state parameter (default: auto-generated) + options.codeVerifier: string? - custom PKCE verifier (default: auto-generated) + +Returns: { url: string, state: string, codeVerifier: string } + url: The full authorization URL to open in a browser + state: The state parameter (for CSRF verification) + codeVerifier: The PKCE code verifier (store for token exchange) + +Implementation: +1. Generate or use provided state and codeVerifier +2. Compute code challenge: base64url(sha256(codeVerifier)) +3. Build authorization URL (same as signInWithOAuth step 5) +4. Return { url, state, codeVerifier } without redirecting + +The caller is responsible for: +- Opening the URL in a browser/webview +- Storing the state and codeVerifier +- Calling callOAuthCallback() with the callback URL + +Does not error. + + ## signInWithCredential(options) Arguments: @@ -824,14 +854,15 @@ Returns: string - the access token for Convex HTTP requests Does not error. -## Redirect Methods +## Redirect Methods [BROWSER-ONLY] + +These methods are only available in browser environments (JavaScript SDK). +Non-browser SDKs (Swift, Python, etc.) should NOT expose these methods. All redirect methods take optional options: Options: replace: bool? - if true, replace current history entry instead of pushing - - Browser: use location.replace() instead of location.assign() - - Mobile: affects navigation stack behavior noRedirectBack: bool? - if true, don't set after_auth_return_to param Methods: @@ -869,5 +900,4 @@ Implementation: - "none": don't redirect (for headless/API use) - Custom navigate function: call it with the URL -All require browser or framework-specific redirect capability. Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index 2b6a57f9c8..c8a911cda8 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -189,12 +189,19 @@ Returns team associated with the API key. Does not error. -## listTeams() +## listTeams(options?) + +Arguments: + options.userId: string? - filter by user membership Returns: ServerTeam[] Request: GET /api/v1/teams [server-only] + Query params: user_id? + +Note: This endpoint does NOT support pagination parameters like limit/cursor. +Use optional user_id filter to get teams a specific user belongs to. Response: { items: [ServerTeamCrud, ...] } diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md index 1718cc7094..f344b882d1 100644 --- a/sdks/spec/src/types/teams/server-team.spec.md +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -38,8 +38,9 @@ Does not error. Returns: ServerTeamUser[] -GET /api/v1/teams/{teamId}/users [server-only] -Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts +GET /api/v1/users?team_id={teamId} [server-only] + +Returns all users who are members of the specified team. ServerTeamUser: Extends ServerUser with: @@ -54,7 +55,7 @@ Does not error. userId: string -POST /api/v1/teams/{teamId}/users { user_id } [server-only] +POST /api/v1/team-memberships/{teamId}/{userId} [server-only] Directly adds a user to the team without invitation. @@ -65,7 +66,7 @@ Does not error. userId: string -DELETE /api/v1/teams/{teamId}/users/{userId} [server-only] +DELETE /api/v1/team-memberships/{teamId}/{userId} [server-only] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 68284955c6..395f32bedc 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -63,11 +63,12 @@ Does not error. Returns: TeamUser[] -GET /api/v1/teams/{teamId}/users [authenticated] -Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts +GET /api/v1/team-member-profiles?team_id={teamId} [authenticated] + +Returns all members of the team with their team profiles. TeamUser: - id: string - user ID + id: string - user ID (from user_id field in response) teamProfile: TeamMemberProfile - user's profile within this team See types/teams/team-member-profile.spec.md for TeamMemberProfile. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index 86ca49a8d9..8c65e00d10 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -115,8 +115,7 @@ Errors: Returns: Team[] -GET /api/v1/users/me/teams [authenticated] -Route: apps/backend/src/app/api/latest/users/me/teams/route.ts +GET /api/v1/teams?user_id=me [authenticated] Construct Team for each item. @@ -187,8 +186,7 @@ Does not error. Returns: ContactChannel[] -GET /api/v1/contact-channels [authenticated] -Route: apps/backend/src/app/api/latest/contact-channels/route.ts +GET /api/v1/contact-channels?user_id=me [authenticated] Does not error. diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md index d3206321d8..6af5029ef7 100644 --- a/sdks/spec/src/types/users/server-user.spec.md +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -71,6 +71,18 @@ Shorthand for update({ clientReadOnlyMetadata: metadata }). Does not error. +### setPassword(password) + +password: string + +Server-side password setting. Shorthand for update({ password: password }). + +Note: Unlike client-side setPassword (which uses POST /auth/password/set), +server-side password setting is done via the user update endpoint. + +Does not error. + + ## Team Methods @@ -90,7 +102,7 @@ Does not error. Returns: ServerTeam[] -GET /api/v1/users/{userId}/teams [server-only] +GET /api/v1/teams?user_id={userId} [server-only] Does not error. @@ -113,7 +125,7 @@ Does not error. Returns: ServerContactChannel[] -GET /api/v1/users/{userId}/contact-channels [server-only] +GET /api/v1/contact-channels?user_id={userId} [server-only] ServerContactChannel extends ContactChannel with: update(data: ServerContactChannelUpdateOptions): Promise From bcb4aa84032342b405e151256663d62a373580e2 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 13:51:10 -0800 Subject: [PATCH 05/17] Lots! --- pnpm-workspace.yaml | 1 + .../Examples/StackAuthMacOS/Package.swift | 21 + .../swift/Examples/StackAuthMacOS/README.md | 111 + .../StackAuthMacOS/StackAuthMacOSApp.swift | 1871 +++++++++++++++++ .../swift/Examples/StackAuthiOS/Package.swift | 21 + .../swift/Examples/StackAuthiOS/README.md | 107 + .../StackAuthiOS/StackAuthiOSApp.swift | 1016 +++++++++ sdks/implementations/swift/README.md | 28 + sdks/implementations/swift/package.json | 12 + 9 files changed, 3188 insertions(+) create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/README.md create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/Package.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/README.md create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift create mode 100644 sdks/implementations/swift/package.json diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fe1fe65c2f..a7166d710e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,5 +4,6 @@ packages: - examples/* - docs - sdks/* + - sdks/implementations/* minimumReleaseAge: 2880 diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift new file mode 100644 index 0000000000..433b0afb04 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StackAuthMacOS", + platforms: [ + .macOS(.v14) + ], + dependencies: [ + .package(name: "StackAuth", path: "../..") + ], + targets: [ + .executableTarget( + name: "StackAuthMacOS", + dependencies: [ + .product(name: "StackAuth", package: "StackAuth") + ], + path: "StackAuthMacOS" + ) + ] +) diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/README.md b/sdks/implementations/swift/Examples/StackAuthMacOS/README.md new file mode 100644 index 0000000000..dbd70f0817 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/README.md @@ -0,0 +1,111 @@ +# Stack Auth macOS Example + +A comprehensive macOS SwiftUI application for testing all Stack Auth SDK functions interactively. + +## Prerequisites + +- macOS 14.0+ +- Swift 5.9+ +- A running Stack Auth backend (default: `http://localhost:8102`) + +## Running the Example + +1. Start the Stack Auth backend: + ```bash + cd /path/to/stack-2 + pnpm run dev + ``` + +2. Open and run the example: + ```bash + cd Examples/StackAuthMacOS + swift run + ``` + + Or open in Xcode: + ```bash + open Package.swift + ``` + +## Features + +The example app provides a sidebar navigation with the following sections: + +### Configuration +- **Settings**: Configure API base URL, project ID, and API keys +- **Logs**: View real-time logs of all SDK operations + +### Client App Testing +- **Authentication** + - Sign up with email/password + - Sign in with credentials + - Sign in with wrong password (error testing) + - Sign out + - Get current user + - Get user (or throw) + +- **User Management** + - Set display name + - Update client metadata + - Update password + - Get access/refresh tokens + - Get auth headers + - Get partial user from token + +- **Teams** + - Create team + - List user's teams + - Get team by ID + - List team members + +- **Contact Channels** + - List contact channels + +- **OAuth** + - Generate OAuth URLs for Google, GitHub, Microsoft + - Test PKCE code generation + +- **Tokens** + - Get access token (JWT format) + - Get refresh token + - Get auth headers + - Test different token stores + +### Server App Testing +- **Server Users** + - Create user (basic and with all options) + - List users with pagination + - Get user by ID + - Delete user + +- **Server Teams** + - Create team + - List all teams + - Add/remove users from teams + - List team users + - Delete team + +- **Sessions** + - Create session (impersonation) + - Use session tokens with client app + +## Default Configuration + +The example is pre-configured for local development: +- Base URL: `http://localhost:8102` +- Project ID: `internal` +- Publishable Key: `this-publishable-client-key-is-for-local-development-only` +- Secret Key: `this-secret-server-key-is-for-local-development-only` + +## SDK Functions Covered + +| Category | Functions | +|----------|-----------| +| Auth | signUpWithCredential, signInWithCredential, signOut, getUser, getOAuthUrl | +| User | setDisplayName, update (metadata), updatePassword, getAccessToken, getRefreshToken, getAuthHeaders, getPartialUser | +| Teams | createTeam, listTeams, getTeam, listUsers (team members) | +| Contact | listContactChannels | +| Server Users | createUser, listUsers, getUser, delete, update (metadata, password) | +| Server Teams | createTeam, listTeams, getTeam, addUser, removeUser, listUsers, delete | +| Sessions | createSession | +| Errors | EmailPasswordMismatchError, UserNotSignedInError, PasswordConfirmationMismatchError | diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift new file mode 100644 index 0000000000..3af7509d25 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -0,0 +1,1871 @@ +import SwiftUI +import AppKit +import StackAuth + +@main +struct StackAuthMacOSApp: App { + init() { + // Required for SwiftUI apps run from command line (not .app bundle) + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - Main Content View + +struct ContentView: View { + @State private var viewModel = SDKTestViewModel() + + var body: some View { + HSplitView { + // Left: Navigation + Controls + NavigationSplitView { + List(selection: $viewModel.selectedSection) { + Section("Configuration") { + Label("Settings", systemImage: "gear") + .tag(TestSection.settings) + } + + Section("Client App") { + Label("Authentication", systemImage: "person.badge.key") + .tag(TestSection.authentication) + Label("User Management", systemImage: "person.crop.circle") + .tag(TestSection.userManagement) + Label("Teams", systemImage: "person.3") + .tag(TestSection.teams) + Label("Contact Channels", systemImage: "envelope") + .tag(TestSection.contactChannels) + Label("OAuth", systemImage: "link") + .tag(TestSection.oauth) + Label("Tokens", systemImage: "key") + .tag(TestSection.tokens) + } + + Section("Server App") { + Label("Server Users", systemImage: "person.badge.shield.checkmark") + .tag(TestSection.serverUsers) + Label("Server Teams", systemImage: "person.3.fill") + .tag(TestSection.serverTeams) + Label("Sessions", systemImage: "rectangle.stack.person.crop") + .tag(TestSection.sessions) + } + } + .listStyle(.sidebar) + .navigationTitle("Stack Auth SDK") + } detail: { + Group { + switch viewModel.selectedSection { + case .settings: + SettingsView(viewModel: viewModel) + case .authentication: + AuthenticationView(viewModel: viewModel) + case .userManagement: + UserManagementView(viewModel: viewModel) + case .teams: + TeamsView(viewModel: viewModel) + case .contactChannels: + ContactChannelsView(viewModel: viewModel) + case .oauth: + OAuthView(viewModel: viewModel) + case .tokens: + TokensView(viewModel: viewModel) + case .serverUsers: + ServerUsersView(viewModel: viewModel) + case .serverTeams: + ServerTeamsView(viewModel: viewModel) + case .sessions: + SessionsView(viewModel: viewModel) + } + } + .frame(minWidth: 400) + } + .frame(minWidth: 500) + + // Right: Log Panel (always visible) + LogPanelView(viewModel: viewModel) + .frame(minWidth: 400, idealWidth: 500) + } + .frame(minWidth: 1100, minHeight: 700) + } +} + +// MARK: - Log Panel View + +struct LogPanelView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var selectedLogId: UUID? + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("SDK Activity Log") + .font(.headline) + Spacer() + Text("\(viewModel.logs.count) entries") + .foregroundStyle(.secondary) + .font(.caption) + Button("Clear") { + viewModel.clearLogs() + } + .buttonStyle(.borderless) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor)) + + Divider() + + // Log entries + if viewModel.logs.isEmpty { + VStack { + Spacer() + Text("No activity yet") + .foregroundStyle(.secondary) + Text("Click buttons on the left to test SDK functions") + .font(.caption) + .foregroundStyle(.tertiary) + Spacer() + } + } else { + ScrollViewReader { proxy in + List(viewModel.logs, selection: $selectedLogId) { entry in + LogEntryView(entry: entry) + .id(entry.id) + .contextMenu { + Button("Copy Message") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.message, forType: .string) + } + Button("Copy Full Details") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.fullDescription, forType: .string) + } + if let details = entry.details { + Button("Copy Details JSON") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(details, forType: .string) + } + } + } + } + .listStyle(.plain) + .onChange(of: viewModel.logs.first?.id) { _, newId in + if let id = newId { + withAnimation { + proxy.scrollTo(id, anchor: .top) + } + } + } + } + } + + Divider() + + // Selected log details + if let selectedId = selectedLogId, + let entry = viewModel.logs.first(where: { $0.id == selectedId }) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Details") + .font(.caption.bold()) + Spacer() + Button("Copy All") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.fullDescription, forType: .string) + } + .buttonStyle(.borderless) + .font(.caption) + } + + ScrollView { + Text(entry.fullDescription) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(8) + .frame(height: 150) + .background(Color(NSColor.textBackgroundColor)) + } + } + .background(Color(NSColor.windowBackgroundColor)) + } +} + +struct LogEntryView: View { + let entry: LogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .top) { + // Icon + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + // Function call + if let function = entry.function { + Text(function) + .font(.system(.caption, design: .monospaced).bold()) + .foregroundStyle(.primary) + } + + // Message + Text(entry.message) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(entry.type.color) + .lineLimit(3) + + // Timestamp + Text(entry.timestamp, style: .time) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer() + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Test Sections + +enum TestSection: String, CaseIterable, Identifiable { + case settings + case authentication + case userManagement + case teams + case contactChannels + case oauth + case tokens + case serverUsers + case serverTeams + case sessions + + var id: String { rawValue } +} + +// MARK: - View Model + +@Observable +class SDKTestViewModel { + // Configuration + var baseUrl = "http://localhost:8102" + var projectId = "internal" + var publishableClientKey = "this-publishable-client-key-is-for-local-development-only" + var secretServerKey = "this-secret-server-key-is-for-local-development-only" + + // State + var selectedSection: TestSection = .settings + var logs: [LogEntry] = [] + var isLoading = false + + // Apps (lazy initialized) + private var _clientApp: StackClientApp? + private var _serverApp: StackServerApp? + + var clientApp: StackClientApp { + if _clientApp == nil { + _clientApp = StackClientApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + baseUrl: baseUrl, + tokenStore: .memory, + noAutomaticPrefetch: true + ) + } + return _clientApp! + } + + var serverApp: StackServerApp { + if _serverApp == nil { + _serverApp = StackServerApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + secretServerKey: secretServerKey, + baseUrl: baseUrl + ) + } + return _serverApp! + } + + func resetApps() { + _clientApp = nil + _serverApp = nil + logCall("resetApps()", result: "Apps reset with new configuration") + } + + // Enhanced logging + func logCall(_ function: String, params: String? = nil, result: String) { + let message = result + let details = params.map { "Parameters:\n\($0)\n\nResult:\n\(result)" } ?? "Result:\n\(result)" + let entry = LogEntry( + function: function, + message: message, + details: details, + type: .success, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + func logCall(_ function: String, params: String? = nil, error: Error) { + let errorStr = String(describing: error) + let message = errorStr + let details = params.map { "Parameters:\n\($0)\n\nError:\n\(errorStr)" } ?? "Error:\n\(errorStr)" + let entry = LogEntry( + function: function, + message: message, + details: details, + type: .error, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + func logInfo(_ function: String, message: String, details: String? = nil) { + let entry = LogEntry( + function: function, + message: message, + details: details ?? message, + type: .info, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + private func trimLogs() { + if logs.count > 200 { + logs.removeLast(logs.count - 200) + } + } + + func clearLogs() { + logs.removeAll() + } +} + +struct LogEntry: Identifiable { + let id = UUID() + let function: String? + let message: String + let details: String? + let type: LogType + let timestamp: Date + + var fullDescription: String { + var parts: [String] = [] + parts.append("Time: \(timestamp.formatted(date: .omitted, time: .standard))") + if let function = function { + parts.append("Function: \(function)") + } + parts.append("Status: \(type.rawValue)") + parts.append("Message: \(message)") + if let details = details { + parts.append("\nDetails:\n\(details)") + } + return parts.joined(separator: "\n") + } +} + +enum LogType: String { + case info = "INFO" + case success = "SUCCESS" + case error = "ERROR" + + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .error: return .red + } + } + + var icon: String { + switch self { + case .info: return "info.circle" + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + } + } +} + +// MARK: - Object Serialization Helpers + +/// Converts any value to a pretty-printed string representation +func formatValue(_ value: Any?, indent: Int = 0) -> String { + let spaces = String(repeating: " ", count: indent) + + guard let value = value else { return "nil" } + + switch value { + case let str as String: + return "\"\(str)\"" + case let bool as Bool: + return bool ? "true" : "false" + case let num as NSNumber: + return "\(num)" + case let date as Date: + return "\"\(date.formatted())\"" + case let url as URL: + return "\"\(url.absoluteString)\"" + case let dict as [String: Any]: + if dict.isEmpty { return "{}" } + var lines = ["{"] + for (key, val) in dict.sorted(by: { $0.key < $1.key }) { + lines.append("\(spaces) \(key): \(formatValue(val, indent: indent + 1))") + } + lines.append("\(spaces)}") + return lines.joined(separator: "\n") + case let arr as [Any]: + if arr.isEmpty { return "[]" } + var lines = ["["] + for item in arr { + lines.append("\(spaces) \(formatValue(item, indent: indent + 1)),") + } + lines.append("\(spaces)]") + return lines.joined(separator: "\n") + default: + return String(describing: value) + } +} + +/// Serializes a CurrentUser to a dictionary for logging +func serializeCurrentUser(_ user: CurrentUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = await user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + dict["isAnonymous"] = await user.isAnonymous + dict["isRestricted"] = await user.isRestricted + if let reason = await user.restrictedReason { + dict["restrictedReason"] = String(describing: reason) + } + let providers = await user.oauthProviders + if !providers.isEmpty { + dict["oauthProviders"] = providers.map { ["id": $0.id] } + } + if let team = await user.selectedTeam { + dict["selectedTeam"] = ["id": team.id, "displayName": await team.displayName] + } + return dict +} + +/// Serializes a ServerUser to a dictionary for logging +func serializeServerUser(_ user: ServerUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + if let lastActiveAt = await user.lastActiveAt { + dict["lastActiveAt"] = lastActiveAt.formatted() + } + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["serverMetadata"] = await user.serverMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + return dict +} + +/// Serializes a Team to a dictionary for logging +func serializeTeam(_ team: Team) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + return dict +} + +/// Serializes a ServerTeam to a dictionary for logging +func serializeServerTeam(_ team: ServerTeam) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + dict["serverMetadata"] = await team.serverMetadata + dict["createdAt"] = await team.createdAt.formatted() + return dict +} + +/// Serializes a ContactChannel to a dictionary for logging +func serializeContactChannel(_ channel: ContactChannel) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = channel.id + dict["type"] = await channel.type + dict["value"] = await channel.value + dict["isPrimary"] = await channel.isPrimary + dict["isVerified"] = await channel.isVerified + dict["usedForAuth"] = await channel.usedForAuth + return dict +} + +/// Serializes a TeamUser to a dictionary for logging +func serializeTeamUser(_ user: TeamUser) -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["teamProfile"] = [ + "displayName": user.teamProfile.displayName as Any, + "profileImageUrl": user.teamProfile.profileImageUrl as Any + ] + return dict +} + +/// Formats a dictionary as a pretty object string +func formatObject(_ name: String, _ dict: [String: Any]) -> String { + var lines = ["\(name) {"] + for (key, value) in dict.sorted(by: { $0.key < $1.key }) { + let formattedValue = formatValue(value, indent: 1) + if formattedValue.contains("\n") { + lines.append(" \(key): \(formattedValue)") + } else { + lines.append(" \(key): \(formattedValue)") + } + } + lines.append("}") + return lines.joined(separator: "\n") +} + +/// Formats an array of dictionaries as a pretty array string +func formatObjectArray(_ name: String, _ items: [[String: Any]]) -> String { + if items.isEmpty { + return "\(name) []" + } + var lines = ["\(name) ["] + for (index, item) in items.enumerated() { + lines.append(" [\(index)] {") + for (key, value) in item.sorted(by: { $0.key < $1.key }) { + lines.append(" \(key): \(formatValue(value, indent: 2))") + } + lines.append(" }") + } + lines.append("]") + lines.append("Total: \(items.count) items") + return lines.joined(separator: "\n") +} + +// MARK: - Settings View + +struct SettingsView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + Form { + Section("API Configuration") { + TextField("Base URL", text: $viewModel.baseUrl) + TextField("Project ID", text: $viewModel.projectId) + TextField("Publishable Client Key", text: $viewModel.publishableClientKey) + SecureField("Secret Server Key", text: $viewModel.secretServerKey) + + Button("Apply Configuration") { + viewModel.resetApps() + } + .buttonStyle(.borderedProminent) + } + + Section("Quick Actions") { + Button("Test Connection") { + Task { await testConnection() } + } + } + } + .formStyle(.grouped) + .navigationTitle("Settings") + } + + func testConnection() async { + viewModel.logInfo("testConnection()", message: "Testing connection to \(viewModel.baseUrl)...") + do { + let project = try await viewModel.clientApp.getProject() + viewModel.logCall( + "getProject()", + result: "Connected! Project ID: \(project.id)" + ) + } catch { + viewModel.logCall("getProject()", error: error) + } + } +} + +// MARK: - Authentication View + +struct AuthenticationView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var password = "TestPassword123!" + @State private var currentUser: String? + + var body: some View { + Form { + Section("Credentials") { + TextField("Email", text: $email) + SecureField("Password", text: $password) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") + } + } + + Section("Sign Up") { + Button("signUpWithCredential(email, password)") { + Task { await signUp() } + } + .disabled(email.isEmpty || password.isEmpty) + } + + Section("Sign In") { + Button("signInWithCredential(email, password)") { + Task { await signIn() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("signInWithCredential(email, WRONG_PASSWORD)") { + Task { await signInWrongPassword() } + } + .disabled(email.isEmpty) + } + + Section("Sign Out") { + Button("signOut()") { + Task { await signOut() } + } + } + + Section("Current User") { + Button("getUser()") { + Task { await getUser() } + } + + Button("getUser(or: .throw)") { + Task { await getUserOrThrow() } + } + + if let user = currentUser { + Text(user) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + .formStyle(.grouped) + .navigationTitle("Authentication") + } + + func signUp() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params) + + do { + try await viewModel.clientApp.signUpWithCredential(email: email, password: password) + viewModel.logCall( + "signUpWithCredential(email, password)", + params: params, + result: "Success! User signed up." + ) + await getUser() + } catch { + viewModel.logCall("signUpWithCredential(email, password)", params: params, error: error) + } + } + + func signIn() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signInWithCredential()", message: "Calling...", details: params) + + do { + try await viewModel.clientApp.signInWithCredential(email: email, password: password) + viewModel.logCall( + "signInWithCredential(email, password)", + params: params, + result: "Success! User signed in." + ) + await getUser() + } catch { + viewModel.logCall("signInWithCredential(email, password)", params: params, error: error) + } + } + + func signInWrongPassword() async { + let params = "email: \"\(email)\"\npassword: \"WrongPassword!\"" + viewModel.logInfo("signInWithCredential()", message: "Calling with wrong password...", details: params) + + do { + try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Unexpected success (should have failed)" + ) + } catch let error as EmailPasswordMismatchError { + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error) + } + } + + func signOut() async { + viewModel.logInfo("signOut()", message: "Calling...") + + do { + try await viewModel.clientApp.signOut() + viewModel.logCall("signOut()", result: "Success! User signed out.") + currentUser = nil + } catch { + viewModel.logCall("signOut()", error: error) + } + } + + func getUser() async { + viewModel.logInfo("getUser()", message: "Calling...") + + do { + let user = try await viewModel.clientApp.getUser() + if let user = user { + let dict = await serializeCurrentUser(user) + currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")" + viewModel.logCall( + "getUser()", + result: formatObject("CurrentUser", dict) + ) + } else { + currentUser = nil + viewModel.logCall("getUser()", result: "nil (no user signed in)") + } + } catch { + viewModel.logCall("getUser()", error: error) + } + } + + func getUserOrThrow() async { + viewModel.logInfo("getUser(or: .throw)", message: "Calling...") + + do { + let user = try await viewModel.clientApp.getUser(or: .throw) + if let user = user { + let dict = await serializeCurrentUser(user) + viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict)) + } else { + viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)") + } + } catch let error as UserNotSignedInError { + viewModel.logCall( + "getUser(or: .throw)", + result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("getUser(or: .throw)", error: error) + } + } +} + +// MARK: - User Management View + +struct UserManagementView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var displayName = "" + @State private var metadataKey = "theme" + @State private var metadataValue = "dark" + @State private var oldPassword = "TestPassword123!" + @State private var newPassword = "NewPassword456!" + + var body: some View { + Form { + Section("Display Name") { + TextField("Display Name", text: $displayName) + + Button("user.setDisplayName(displayName)") { + Task { await setDisplayName() } + } + .disabled(displayName.isEmpty) + } + + Section("Client Metadata") { + TextField("Key", text: $metadataKey) + TextField("Value", text: $metadataValue) + + Button("user.update(clientMetadata: {key: value})") { + Task { await updateMetadata() } + } + } + + Section("Password") { + SecureField("Old Password", text: $oldPassword) + SecureField("New Password", text: $newPassword) + + Button("user.updatePassword(oldPassword, newPassword)") { + Task { await updatePassword() } + } + + Button("user.updatePassword(WRONG_OLD, newPassword)") { + Task { await updatePasswordWrong() } + } + } + + Section("Token Info") { + Button("getAccessToken()") { + Task { await getAccessToken() } + } + + Button("getRefreshToken()") { + Task { await getRefreshToken() } + } + + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } + } + + Button("getPartialUser()") { + Task { await getPartialUser() } + } + } + } + .formStyle(.grouped) + .navigationTitle("User Management") + } + + func setDisplayName() async { + let params = "displayName: \"\(displayName)\"" + viewModel.logInfo("setDisplayName()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("setDisplayName()", result: "Error: No user signed in") + return + } + try await user.setDisplayName(displayName) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.setDisplayName(displayName)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) + } catch { + viewModel.logCall("user.setDisplayName(displayName)", params: params, error: error) + } + } + + func updateMetadata() async { + let params = "clientMetadata: {\"\(metadataKey)\": \"\(metadataValue)\"}" + viewModel.logInfo("update(clientMetadata:)", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in") + return + } + try await user.update(clientMetadata: [metadataKey: metadataValue]) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.update(clientMetadata:)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) + } catch { + viewModel.logCall("user.update(clientMetadata:)", params: params, error: error) + } + } + + func updatePassword() async { + let params = "oldPassword: \"\(oldPassword)\"\nnewPassword: \"\(newPassword)\"" + viewModel.logInfo("updatePassword()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("updatePassword()", result: "Error: No user signed in") + return + } + try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword) + viewModel.logCall( + "user.updatePassword(old, new)", + params: params, + result: "Success! Password updated." + ) + } catch { + viewModel.logCall("user.updatePassword(old, new)", params: params, error: error) + } + } + + func updatePasswordWrong() async { + let params = "oldPassword: \"WrongPassword!\"\nnewPassword: \"\(newPassword)\"" + viewModel.logInfo("updatePassword()", message: "Calling with wrong old password...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("updatePassword()", result: "Error: No user signed in") + return + } + try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword) + viewModel.logCall( + "user.updatePassword(WRONG, new)", + params: params, + result: "Unexpected success" + ) + } catch let error as PasswordConfirmationMismatchError { + viewModel.logCall( + "user.updatePassword(WRONG, new)", + params: params, + result: "Expected error caught!\nType: PasswordConfirmationMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("user.updatePassword(WRONG, new)", params: params, error: error) + } + } + + func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)" + ) + } else { + viewModel.logCall("getAccessToken()", result: "nil (not signed in)") + } + } + + func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token (\(token.count) chars):\n\(token)" + ) + } else { + viewModel.logCall("getRefreshToken()", result: "nil (not signed in)") + } + } + + func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + + let headers = await viewModel.clientApp.getAuthHeaders() + var result = "Headers:\n" + for (key, value) in headers { + result += " \(key): \(value)\n" + } + viewModel.logCall("getAuthHeaders()", result: result) + } + + func getPartialUser() async { + viewModel.logInfo("getPartialUser()", message: "Calling...") + + let user = await viewModel.clientApp.getPartialUser() + if let user = user { + viewModel.logCall( + "getPartialUser()", + result: "PartialUser {\n id: \"\(user.id)\"\n primaryEmail: \"\(user.primaryEmail ?? "nil")\"\n}" + ) + } else { + viewModel.logCall("getPartialUser()", result: "nil (not signed in)") + } + } +} + +// MARK: - Teams View + +struct TeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teams: [(id: String, name: String)] = [] + @State private var selectedTeamId = "" + + var body: some View { + Form { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") + } + + Button("user.createTeam(displayName: teamName)") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("List Teams") { + Button("user.listTeams()") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + HStack { + Text(team.name) + Spacer() + Text(team.id) + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + selectedTeamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected team: \(team.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("Team Operations") { + TextField("Team ID", text: $selectedTeamId) + + Button("user.getTeam(id: teamId)") { + Task { await getTeam() } + } + .disabled(selectedTeamId.isEmpty) + + Button("team.listUsers()") { + Task { await listTeamMembers() } + } + .disabled(selectedTeamId.isEmpty) + } + } + .formStyle(.grouped) + .navigationTitle("Teams") + } + + func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("createTeam()", result: "Error: No user signed in") + return + } + let team = try await user.createTeam(displayName: teamName) + let dict = await serializeTeam(team) + viewModel.logCall( + "user.createTeam(displayName:)", + params: params, + result: formatObject("Team", dict) + ) + await listTeams() + } catch { + viewModel.logCall("user.createTeam(displayName:)", params: params, error: error) + } + } + + func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("listTeams()", result: "Error: No user signed in") + return + } + let teamsList = try await user.listTeams() + var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] + for team in teamsList { + let dict = await serializeTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) + } + teams = results + viewModel.logCall("user.listTeams()", result: formatObjectArray("Team", dicts)) + } catch { + viewModel.logCall("user.listTeams()", error: error) + } + } + + func getTeam() async { + let params = "id: \"\(selectedTeamId)\"" + viewModel.logInfo("getTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("getTeam()", result: "Error: No user signed in") + return + } + let team = try await user.getTeam(id: selectedTeamId) + if let team = team { + let dict = await serializeTeam(team) + viewModel.logCall( + "user.getTeam(id:)", + params: params, + result: formatObject("Team", dict) + ) + } else { + viewModel.logCall("user.getTeam(id:)", params: params, result: "nil (team not found or not a member)") + } + } catch { + viewModel.logCall("user.getTeam(id:)", params: params, error: error) + } + } + + func listTeamMembers() async { + let params = "teamId: \"\(selectedTeamId)\"" + viewModel.logInfo("team.listUsers()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("team.listUsers()", result: "Error: No user signed in") + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found") + return + } + let members = try await team.listUsers() + let dicts = members.map { serializeTeamUser($0) } + viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts)) + } catch { + viewModel.logCall("team.listUsers()", params: params, error: error) + } + } +} + +// MARK: - Contact Channels View + +struct ContactChannelsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var channels: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + + var body: some View { + Form { + Section("Contact Channels") { + Button("user.listContactChannels()") { + Task { await listChannels() } + } + + ForEach(channels, id: \.id) { channel in + HStack { + Text(channel.value) + Spacer() + if channel.isPrimary { + Text("Primary") + .font(.caption) + .foregroundStyle(.blue) + } + if channel.isVerified { + Text("Verified") + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + } + .formStyle(.grouped) + .navigationTitle("Contact Channels") + } + + func listChannels() async { + viewModel.logInfo("listContactChannels()", message: "Calling...") + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("listContactChannels()", result: "Error: No user signed in") + return + } + let channelsList = try await user.listContactChannels() + var results: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + var dicts: [[String: Any]] = [] + for channel in channelsList { + let dict = await serializeContactChannel(channel) + dicts.append(dict) + results.append(( + id: channel.id, + value: dict["value"] as? String ?? "", + isPrimary: dict["isPrimary"] as? Bool ?? false, + isVerified: dict["isVerified"] as? Bool ?? false + )) + } + channels = results + viewModel.logCall("user.listContactChannels()", result: formatObjectArray("ContactChannel", dicts)) + } catch { + viewModel.logCall("user.listContactChannels()", error: error) + } + } +} + +// MARK: - OAuth View + +struct OAuthView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var provider = "google" + + var body: some View { + Form { + Section("OAuth URL Generation") { + TextField("Provider", text: $provider) + + HStack { + Button("google") { provider = "google" } + Button("github") { provider = "github" } + Button("microsoft") { provider = "microsoft" } + } + + Button("getOAuthUrl(provider: \"\(provider)\")") { + Task { await getOAuthUrl() } + } + } + } + .formStyle(.grouped) + .navigationTitle("OAuth") + } + + func getOAuthUrl() async { + let params = "provider: \"\(provider)\"" + viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) + + do { + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) + viewModel.logCall( + "getOAuthUrl(provider:)", + params: params, + result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n}" + ) + } catch { + viewModel.logCall("getOAuthUrl(provider:)", params: params, error: error) + } + } +} + +// MARK: - Tokens View + +struct TokensView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + Form { + Section("Token Operations") { + Button("getAccessToken()") { + Task { await getAccessToken() } + } + + Button("getRefreshToken()") { + Task { await getRefreshToken() } + } + + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } + } + } + + Section("Token Store Types") { + Button("Test Memory Store") { + Task { await testMemoryStore() } + } + + Button("Test Explicit Store") { + Task { await testExplicitStore() } + } + } + } + .formStyle(.grouped) + .navigationTitle("Tokens") + } + + func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token:\n Parts: \(parts.count)\n Length: \(token.count) chars\n Token: \(token)" + ) + } else { + viewModel.logCall("getAccessToken()", result: "nil") + } + } + + func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token:\n Length: \(token.count) chars\n Token: \(token)" + ) + } else { + viewModel.logCall("getRefreshToken()", result: "nil") + } + } + + func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + + let headers = await viewModel.clientApp.getAuthHeaders() + var result = "Headers {\n" + for (key, value) in headers { + result += " \"\(key)\": \"\(value)\"\n" + } + result += "}" + viewModel.logCall("getAuthHeaders()", result: result) + } + + func testMemoryStore() async { + viewModel.logInfo("StackClientApp(tokenStore: .memory)", message: "Creating app with memory store...") + + let app = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .memory, + noAutomaticPrefetch: true + ) + let token = await app.getAccessToken() + viewModel.logCall( + "StackClientApp(tokenStore: .memory)", + result: "Created app with memory store\ngetAccessToken() = \(token == nil ? "nil" : "present")" + ) + } + + func testExplicitStore() async { + viewModel.logInfo("Testing explicit token store...", message: "Getting tokens from current app...") + + let accessToken = await viewModel.clientApp.getAccessToken() + let refreshToken = await viewModel.clientApp.getRefreshToken() + + guard let at = accessToken, let rt = refreshToken else { + viewModel.logCall("testExplicitStore()", result: "Error: No tokens available. Sign in first.") + return + } + + let app = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .explicit(accessToken: at, refreshToken: rt), + noAutomaticPrefetch: true + ) + + do { + let user = try await app.getUser() + if let user = user { + let email = await user.primaryEmail + viewModel.logCall( + "StackClientApp(tokenStore: .explicit(...))", + result: "Success! Created app with explicit tokens\ngetUser() returned: \(email ?? "no email")" + ) + } else { + viewModel.logCall( + "StackClientApp(tokenStore: .explicit(...))", + result: "App created but getUser() returned nil" + ) + } + } catch { + viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error) + } + } +} + +// MARK: - Server Users View + +struct ServerUsersView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var displayName = "" + @State private var userId = "" + @State private var users: [(id: String, email: String?)] = [] + + var body: some View { + Form { + Section("Create User") { + TextField("Email", text: $email) + TextField("Display Name (optional)", text: $displayName) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") + } + + Button("serverApp.createUser(email: email)") { + Task { await createUser() } + } + .disabled(email.isEmpty) + + Button("serverApp.createUser(email, password, displayName, ...)") { + Task { await createUserWithAllOptions() } + } + .disabled(email.isEmpty) + } + + Section("List Users") { + Button("serverApp.listUsers(limit: 5)") { + Task { await listUsers() } + } + + ForEach(users, id: \.id) { user in + HStack { + Text(user.email ?? "no email") + Spacer() + Text(user.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + userId = user.id + viewModel.logInfo("selectUser()", message: "Selected: \(user.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("User Operations") { + TextField("User ID", text: $userId) + + Button("serverApp.getUser(id: userId)") { + Task { await getUser() } + } + .disabled(userId.isEmpty) + + Button("user.delete()") { + Task { await deleteUser() } + } + .disabled(userId.isEmpty) + } + } + .formStyle(.grouped) + .navigationTitle("Server Users") + } + + func createUser() async { + let params = "email: \"\(email)\"" + viewModel.logInfo("createUser()", message: "Calling...", details: params) + + do { + let user = try await viewModel.serverApp.createUser(email: email) + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.createUser(email:)", + params: params, + result: formatObject("ServerUser", dict) + ) + userId = user.id + await listUsers() + } catch { + viewModel.logCall("serverApp.createUser(email:)", params: params, error: error) + } + } + + func createUserWithAllOptions() async { + let params = """ + email: "\(email)" + password: "TestPassword123!" + displayName: "\(displayName.isEmpty ? "nil" : displayName)" + primaryEmailVerified: true + clientMetadata: {"source": "macOS-example"} + serverMetadata: {"created_via": "example-app"} + """ + viewModel.logInfo("createUser(all options)", message: "Calling...", details: params) + + do { + let user = try await viewModel.serverApp.createUser( + email: email, + password: "TestPassword123!", + displayName: displayName.isEmpty ? nil : displayName, + primaryEmailVerified: true, + clientMetadata: ["source": "macOS-example"], + serverMetadata: ["created_via": "example-app"] + ) + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.createUser(...)", + params: params, + result: formatObject("ServerUser", dict) + ) + userId = user.id + await listUsers() + } catch { + viewModel.logCall("serverApp.createUser(...)", params: params, error: error) + } + } + + func listUsers() async { + let params = "limit: 5" + viewModel.logInfo("listUsers()", message: "Calling...", details: params) + + do { + let result = try await viewModel.serverApp.listUsers(limit: 5) + var usersList: [(id: String, email: String?)] = [] + var dicts: [[String: Any]] = [] + for user in result.items { + let dict = await serializeServerUser(user) + dicts.append(dict) + usersList.append((id: user.id, email: dict["primaryEmail"] as? String)) + } + users = usersList + viewModel.logCall("serverApp.listUsers(limit:)", params: params, result: formatObjectArray("ServerUser", dicts)) + } catch { + viewModel.logCall("serverApp.listUsers(limit:)", params: params, error: error) + } + } + + func getUser() async { + let params = "id: \"\(userId)\"" + viewModel.logInfo("getUser()", message: "Calling...", details: params) + + do { + let user = try await viewModel.serverApp.getUser(id: userId) + if let user = user { + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.getUser(id:)", + params: params, + result: formatObject("ServerUser", dict) + ) + } else { + viewModel.logCall("serverApp.getUser(id:)", params: params, result: "nil (user not found)") + } + } catch { + viewModel.logCall("serverApp.getUser(id:)", params: params, error: error) + } + } + + func deleteUser() async { + let params = "userId: \"\(userId)\"" + viewModel.logInfo("user.delete()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.serverApp.getUser(id: userId) else { + viewModel.logCall("user.delete()", params: params, result: "Error: User not found") + return + } + try await user.delete() + viewModel.logCall("user.delete()", params: params, result: "Success! User deleted.") + userId = "" + await listUsers() + } catch { + viewModel.logCall("user.delete()", params: params, error: error) + } + } +} + +// MARK: - Server Teams View + +struct ServerTeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teamId = "" + @State private var userIdToAdd = "" + @State private var teams: [(id: String, name: String)] = [] + + var body: some View { + Form { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") + } + + Button("serverApp.createTeam(displayName: teamName)") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("List Teams") { + Button("serverApp.listTeams()") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + HStack { + Text(team.name) + Spacer() + Text(team.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + teamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected: \(team.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("Team Membership") { + TextField("Team ID", text: $teamId) + TextField("User ID", text: $userIdToAdd) + + Button("team.addUser(id: userId)") { + Task { await addUserToTeam() } + } + .disabled(teamId.isEmpty || userIdToAdd.isEmpty) + + Button("team.removeUser(id: userId)") { + Task { await removeUserFromTeam() } + } + .disabled(teamId.isEmpty || userIdToAdd.isEmpty) + + Button("team.listUsers()") { + Task { await listTeamUsers() } + } + .disabled(teamId.isEmpty) + } + + Section("Team Operations") { + Button("team.delete()") { + Task { await deleteTeam() } + } + .disabled(teamId.isEmpty) + } + } + .formStyle(.grouped) + .navigationTitle("Server Teams") + } + + func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + + do { + let team = try await viewModel.serverApp.createTeam(displayName: teamName) + let dict = await serializeServerTeam(team) + viewModel.logCall( + "serverApp.createTeam(displayName:)", + params: params, + result: formatObject("ServerTeam", dict) + ) + teamId = team.id + await listTeams() + } catch { + viewModel.logCall("serverApp.createTeam(displayName:)", params: params, error: error) + } + } + + func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + + do { + let teamsList = try await viewModel.serverApp.listTeams() + var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] + for team in teamsList { + let dict = await serializeServerTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) + } + teams = results + viewModel.logCall("serverApp.listTeams()", result: formatObjectArray("ServerTeam", dicts)) + } catch { + viewModel.logCall("serverApp.listTeams()", error: error) + } + } + + func addUserToTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.addUser()", message: "Calling...", details: params) + + do { + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.logCall("team.addUser()", params: params, result: "Error: Team not found") + return + } + try await team.addUser(id: userIdToAdd) + let dict = await serializeServerTeam(team) + viewModel.logCall("team.addUser(id:)", params: params, result: "Success! User added to team.\n\n" + formatObject("ServerTeam", dict)) + } catch { + viewModel.logCall("team.addUser(id:)", params: params, error: error) + } + } + + func removeUserFromTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.removeUser()", message: "Calling...", details: params) + + do { + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.logCall("team.removeUser()", params: params, result: "Error: Team not found") + return + } + try await team.removeUser(id: userIdToAdd) + let dict = await serializeServerTeam(team) + viewModel.logCall("team.removeUser(id:)", params: params, result: "Success! User removed from team.\n\n" + formatObject("ServerTeam", dict)) + } catch { + viewModel.logCall("team.removeUser(id:)", params: params, error: error) + } + } + + func listTeamUsers() async { + let params = "teamId: \"\(teamId)\"" + viewModel.logInfo("team.listUsers()", message: "Calling...", details: params) + + do { + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found") + return + } + let users = try await team.listUsers() + let dicts = users.map { serializeTeamUser($0) } + viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts)) + } catch { + viewModel.logCall("team.listUsers()", params: params, error: error) + } + } + + func deleteTeam() async { + let params = "teamId: \"\(teamId)\"" + viewModel.logInfo("team.delete()", message: "Calling...", details: params) + + do { + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.logCall("team.delete()", params: params, result: "Error: Team not found") + return + } + try await team.delete() + viewModel.logCall("team.delete()", params: params, result: "Success! Team deleted.") + teamId = "" + await listTeams() + } catch { + viewModel.logCall("team.delete()", params: params, error: error) + } + } +} + +// MARK: - Sessions View + +struct SessionsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var userId = "" + @State private var accessToken = "" + @State private var refreshToken = "" + + var body: some View { + Form { + Section("Create Session (Impersonation)") { + TextField("User ID", text: $userId) + + Button("serverApp.createSession(userId: userId)") { + Task { await createSession() } + } + .disabled(userId.isEmpty) + } + + Section("Session Tokens") { + if !accessToken.isEmpty { + Text("Access Token:") + .font(.headline) + Text(accessToken) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(5) + + Text("Refresh Token:") + .font(.headline) + Text(refreshToken) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + Section("Use Session") { + Button("Create Client with Session Tokens") { + Task { await useSessionTokens() } + } + .disabled(accessToken.isEmpty) + } + } + .formStyle(.grouped) + .navigationTitle("Sessions") + } + + func createSession() async { + let params = "userId: \"\(userId)\"" + viewModel.logInfo("createSession()", message: "Calling...", details: params) + + do { + let tokens = try await viewModel.serverApp.createSession(userId: userId) + accessToken = tokens.accessToken + refreshToken = tokens.refreshToken + viewModel.logCall( + "serverApp.createSession(userId:)", + params: params, + result: """ + SessionTokens { + accessToken: "\(tokens.accessToken.prefix(50))..." + refreshToken: "\(tokens.refreshToken.prefix(30))..." + } + """ + ) + } catch { + viewModel.logCall("serverApp.createSession(userId:)", params: params, error: error) + } + } + + func useSessionTokens() async { + viewModel.logInfo("StackClientApp(tokenStore: .explicit(...))", message: "Creating client with session tokens...") + + do { + let client = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .explicit(accessToken: accessToken, refreshToken: refreshToken), + noAutomaticPrefetch: true + ) + let user = try await client.getUser() + if let user = user { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "clientWithTokens.getUser()", + result: "Success! Authenticated user:\n\n" + formatObject("CurrentUser", dict) + ) + } else { + viewModel.logCall( + "clientWithTokens.getUser()", + result: "nil (tokens may be invalid)" + ) + } + } catch { + viewModel.logCall("clientWithTokens.getUser()", error: error) + } + } +} + +#Preview { + ContentView() +} diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift b/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift new file mode 100644 index 0000000000..ffda99741e --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StackAuthiOS", + platforms: [ + .iOS(.v17) + ], + dependencies: [ + .package(name: "StackAuth", path: "../..") + ], + targets: [ + .executableTarget( + name: "StackAuthiOS", + dependencies: [ + .product(name: "StackAuth", package: "StackAuth") + ], + path: "StackAuthiOS" + ) + ] +) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md new file mode 100644 index 0000000000..171cbf0d3f --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -0,0 +1,107 @@ +# Stack Auth iOS Example + +A comprehensive iOS SwiftUI application for testing all Stack Auth SDK functions interactively. + +## Prerequisites + +- iOS 17.0+ +- Swift 5.9+ +- Xcode 15.0+ +- A running Stack Auth backend accessible from the iOS device/simulator + +## Running the Example + +1. Start the Stack Auth backend: + ```bash + cd /path/to/stack-2 + pnpm run dev + ``` + +2. Open in Xcode: + ```bash + cd Examples/StackAuthiOS + open Package.swift + ``` + +3. Select an iOS simulator or device and run. + +**Note**: When testing on a physical device, update the base URL in Settings to point to your machine's IP address (e.g., `http://192.168.1.x:8102`). + +## Features + +The example app uses a tab-based navigation with the following sections: + +### Auth Tab +- Sign up with email/password +- Sign in with credentials +- Sign in with wrong password (error testing) +- Sign out +- Get current user +- Get user (or throw) +- Generate OAuth URLs (Google, GitHub, Microsoft) + +### User Tab +- Set display name +- Update client metadata +- Update password (correct and wrong old password) +- Get access/refresh tokens +- Get auth headers +- Get partial user from token +- List contact channels + +### Teams Tab +- Create team +- List user's teams +- Select and view team details +- List team members +- Update team name + +### Server Tab +- **Users** + - Create user (basic and with all options) + - List users + - Get/delete user by ID + - Create session (impersonation) + +- **Teams** + - Create team + - List all teams + - Add/remove users from teams + - List team users + - Delete team + +### Settings Tab +- Configure API base URL +- Configure project ID and API keys +- View operation logs + +## Default Configuration + +The example is pre-configured for local development: +- Base URL: `http://localhost:8102` +- Project ID: `internal` +- Publishable Key: `this-publishable-client-key-is-for-local-development-only` +- Secret Key: `this-secret-server-key-is-for-local-development-only` + +## Simulator Network Notes + +When running in the iOS Simulator, `localhost` will connect to your Mac's localhost. For physical devices, use your Mac's local IP address. + +## SDK Functions Covered + +| Category | Functions | +|----------|-----------| +| Auth | signUpWithCredential, signInWithCredential, signOut, getUser, getOAuthUrl | +| User | setDisplayName, update (metadata), updatePassword, getAccessToken, getRefreshToken, getAuthHeaders, getPartialUser | +| Teams | createTeam, listTeams, getTeam, listUsers (team members), update | +| Contact | listContactChannels | +| Server Users | createUser, listUsers, getUser, delete, createSession | +| Server Teams | createTeam, listTeams, getTeam, addUser, removeUser, listUsers, delete | +| Errors | EmailPasswordMismatchError, UserNotSignedInError, PasswordConfirmationMismatchError | + +## Testing Edge Cases + +The app includes buttons specifically for testing error scenarios: +- "Sign In (Wrong Password)" - triggers EmailPasswordMismatchError +- "Get User (or throw)" - triggers UserNotSignedInError when not signed in +- "Update (Wrong Old Password)" - triggers PasswordConfirmationMismatchError diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift new file mode 100644 index 0000000000..1e1fefbe22 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -0,0 +1,1016 @@ +import SwiftUI +import StackAuth + +@main +struct StackAuthiOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - Main Content View + +struct ContentView: View { + @State private var viewModel = SDKTestViewModel() + + var body: some View { + TabView { + NavigationStack { + AuthenticationView(viewModel: viewModel) + } + .tabItem { + Label("Auth", systemImage: "person.badge.key") + } + + NavigationStack { + UserView(viewModel: viewModel) + } + .tabItem { + Label("User", systemImage: "person.crop.circle") + } + + NavigationStack { + TeamsView(viewModel: viewModel) + } + .tabItem { + Label("Teams", systemImage: "person.3") + } + + NavigationStack { + ServerView(viewModel: viewModel) + } + .tabItem { + Label("Server", systemImage: "server.rack") + } + + NavigationStack { + SettingsView(viewModel: viewModel) + } + .tabItem { + Label("Settings", systemImage: "gear") + } + } + } +} + +// MARK: - View Model + +@Observable +class SDKTestViewModel { + // Configuration + var baseUrl = "http://localhost:8102" + var projectId = "internal" + var publishableClientKey = "this-publishable-client-key-is-for-local-development-only" + var secretServerKey = "this-secret-server-key-is-for-local-development-only" + + // State + var logs: [LogEntry] = [] + + // Apps (lazy initialized) + private var _clientApp: StackClientApp? + private var _serverApp: StackServerApp? + + var clientApp: StackClientApp { + if _clientApp == nil { + _clientApp = StackClientApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + baseUrl: baseUrl, + tokenStore: .memory, + noAutomaticPrefetch: true + ) + } + return _clientApp! + } + + var serverApp: StackServerApp { + if _serverApp == nil { + _serverApp = StackServerApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + secretServerKey: secretServerKey, + baseUrl: baseUrl + ) + } + return _serverApp! + } + + func resetApps() { + _clientApp = nil + _serverApp = nil + log("Apps reset with new configuration", type: .info) + } + + func log(_ message: String, type: LogType = .info) { + let entry = LogEntry(message: message, type: type, timestamp: Date()) + logs.insert(entry, at: 0) + if logs.count > 50 { + logs.removeLast() + } + } + + func clearLogs() { + logs.removeAll() + } +} + +struct LogEntry: Identifiable { + let id = UUID() + let message: String + let type: LogType + let timestamp: Date +} + +enum LogType { + case info, success, error + + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .error: return .red + } + } +} + +// MARK: - Settings View + +struct SettingsView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + List { + Section("API Configuration") { + TextField("Base URL", text: $viewModel.baseUrl) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Project ID", text: $viewModel.projectId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Publishable Client Key", text: $viewModel.publishableClientKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Secret Server Key", text: $viewModel.secretServerKey) + + Button("Apply Configuration") { + viewModel.resetApps() + } + } + + Section("Logs (\(viewModel.logs.count))") { + Button("Clear Logs") { + viewModel.clearLogs() + } + + ForEach(viewModel.logs) { entry in + VStack(alignment: .leading) { + Text(entry.timestamp, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + Text(entry.message) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(entry.type.color) + } + } + } + } + .navigationTitle("Settings") + } +} + +// MARK: - Authentication View + +struct AuthenticationView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var password = "TestPassword123!" + @State private var currentUserEmail: String? + @State private var currentUserId: String? + + var body: some View { + List { + Section("Credentials") { + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + SecureField("Password", text: $password) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + } + } + + Section("Actions") { + Button("Sign Up") { + Task { await signUp() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("Sign In") { + Task { await signIn() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("Sign In (Wrong Password)") { + Task { await signInWrongPassword() } + } + .disabled(email.isEmpty) + + Button("Sign Out") { + Task { await signOut() } + } + } + + Section("Current User") { + Button("Refresh User") { + Task { await getUser() } + } + + if let email = currentUserEmail, let id = currentUserId { + Text("Email: \(email)") + Text("ID: \(id)") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Not signed in") + .foregroundStyle(.secondary) + } + } + + Section("OAuth") { + Button("Get Google OAuth URL") { + Task { await getOAuthUrl("google") } + } + Button("Get GitHub OAuth URL") { + Task { await getOAuthUrl("github") } + } + Button("Get Microsoft OAuth URL") { + Task { await getOAuthUrl("microsoft") } + } + } + + Section("Error Testing") { + Button("Get User (or throw)") { + Task { await getUserOrThrow() } + } + } + } + .navigationTitle("Authentication") + .onAppear { + Task { await getUser() } + } + } + + func signUp() async { + do { + viewModel.log("Signing up: \(email)") + try await viewModel.clientApp.signUpWithCredential(email: email, password: password) + viewModel.log("Sign up successful!", type: .success) + await getUser() + } catch { + viewModel.log("Sign up failed: \(error)", type: .error) + } + } + + func signIn() async { + do { + viewModel.log("Signing in: \(email)") + try await viewModel.clientApp.signInWithCredential(email: email, password: password) + viewModel.log("Sign in successful!", type: .success) + await getUser() + } catch { + viewModel.log("Sign in failed: \(error)", type: .error) + } + } + + func signInWrongPassword() async { + do { + viewModel.log("Signing in with wrong password...") + try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") + viewModel.log("Sign in succeeded (unexpected)", type: .error) + } catch let error as EmailPasswordMismatchError { + viewModel.log("Got EmailPasswordMismatchError: \(error.message)", type: .success) + } catch { + viewModel.log("Unexpected error: \(error)", type: .error) + } + } + + func signOut() async { + do { + viewModel.log("Signing out...") + try await viewModel.clientApp.signOut() + viewModel.log("Sign out successful!", type: .success) + currentUserEmail = nil + currentUserId = nil + } catch { + viewModel.log("Sign out failed: \(error)", type: .error) + } + } + + func getUser() async { + do { + let user = try await viewModel.clientApp.getUser() + if let user = user { + currentUserEmail = await user.primaryEmail + currentUserId = await user.id + viewModel.log("Got user: \(currentUserEmail ?? "nil")", type: .success) + } else { + currentUserEmail = nil + currentUserId = nil + viewModel.log("No user signed in", type: .info) + } + } catch { + viewModel.log("Get user failed: \(error)", type: .error) + } + } + + func getUserOrThrow() async { + do { + viewModel.log("Getting user (or throw)...") + let user = try await viewModel.clientApp.getUser(or: .throw) + if let user = user { + let email = await user.primaryEmail + viewModel.log("Got user: \(email ?? "nil")", type: .success) + } else { + viewModel.log("No user (unexpected with .throw)", type: .error) + } + } catch let error as UserNotSignedInError { + viewModel.log("Got UserNotSignedInError: \(error.message)", type: .success) + } catch { + viewModel.log("Unexpected error: \(error)", type: .error) + } + } + + func getOAuthUrl(_ provider: String) async { + do { + viewModel.log("Getting OAuth URL for \(provider)...") + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) + viewModel.log("URL: \(result.url)", type: .success) + viewModel.log("State: \(result.state.prefix(20))...", type: .info) + } catch { + viewModel.log("Get OAuth URL failed: \(error)", type: .error) + } + } +} + +// MARK: - User View + +struct UserView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var displayName = "" + @State private var metadataKey = "theme" + @State private var metadataValue = "dark" + @State private var oldPassword = "TestPassword123!" + @State private var newPassword = "NewPassword456!" + @State private var channels: [(id: String, value: String, isPrimary: Bool)] = [] + + var body: some View { + List { + Section("Display Name") { + TextField("Display Name", text: $displayName) + + Button("Set Display Name") { + Task { await setDisplayName() } + } + .disabled(displayName.isEmpty) + } + + Section("Client Metadata") { + TextField("Key", text: $metadataKey) + TextField("Value", text: $metadataValue) + + Button("Update Metadata") { + Task { await updateMetadata() } + } + } + + Section("Password") { + SecureField("Old Password", text: $oldPassword) + SecureField("New Password", text: $newPassword) + + Button("Update Password") { + Task { await updatePassword() } + } + + Button("Update (Wrong Old Password)") { + Task { await updatePasswordWrong() } + } + } + + Section("Tokens") { + Button("Get Access Token") { + Task { await getAccessToken() } + } + Button("Get Refresh Token") { + Task { await getRefreshToken() } + } + Button("Get Auth Headers") { + Task { await getAuthHeaders() } + } + Button("Get Partial User") { + Task { await getPartialUser() } + } + } + + Section("Contact Channels") { + Button("List Contact Channels") { + Task { await listChannels() } + } + + ForEach(channels, id: \.id) { channel in + HStack { + Text(channel.value) + Spacer() + if channel.isPrimary { + Text("Primary") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + } + } + .navigationTitle("User") + } + + func setDisplayName() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Setting display name: \(displayName)") + try await user.setDisplayName(displayName) + viewModel.log("Display name set!", type: .success) + } catch { + viewModel.log("Set display name failed: \(error)", type: .error) + } + } + + func updateMetadata() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Updating metadata: \(metadataKey)=\(metadataValue)") + try await user.update(clientMetadata: [metadataKey: metadataValue]) + viewModel.log("Metadata updated!", type: .success) + } catch { + viewModel.log("Update metadata failed: \(error)", type: .error) + } + } + + func updatePassword() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Updating password...") + try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword) + viewModel.log("Password updated!", type: .success) + } catch { + viewModel.log("Update password failed: \(error)", type: .error) + } + } + + func updatePasswordWrong() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Updating password with wrong old...") + try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword) + viewModel.log("Password updated (unexpected)", type: .error) + } catch let error as PasswordConfirmationMismatchError { + viewModel.log("Got PasswordConfirmationMismatchError", type: .success) + } catch { + viewModel.log("Unexpected error: \(error)", type: .error) + } + } + + func getAccessToken() async { + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + viewModel.log("Access token: \(token.prefix(40))...", type: .success) + } else { + viewModel.log("No access token", type: .info) + } + } + + func getRefreshToken() async { + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.log("Refresh token: \(token.prefix(20))...", type: .success) + } else { + viewModel.log("No refresh token", type: .info) + } + } + + func getAuthHeaders() async { + let headers = await viewModel.clientApp.getAuthHeaders() + viewModel.log("Auth headers: \(headers.keys.joined(separator: ", "))", type: .success) + } + + func getPartialUser() async { + let user = await viewModel.clientApp.getPartialUser() + if let user = user { + viewModel.log("Partial user: \(user.primaryEmail ?? "nil")", type: .success) + } else { + viewModel.log("No partial user", type: .info) + } + } + + func listChannels() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Listing contact channels...") + let channelsList = try await user.listContactChannels() + var results: [(id: String, value: String, isPrimary: Bool)] = [] + for channel in channelsList { + let value = await channel.value + let isPrimary = await channel.isPrimary + results.append((id: channel.id, value: value, isPrimary: isPrimary)) + } + channels = results + viewModel.log("Found \(channels.count) channels", type: .success) + } catch { + viewModel.log("List channels failed: \(error)", type: .error) + } + } +} + +// MARK: - Teams View + +struct TeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teams: [(id: String, name: String)] = [] + @State private var selectedTeamId = "" + @State private var teamMembers: [String] = [] + + var body: some View { + List { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + } + + Button("Create Team") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("My Teams") { + Button("Refresh Teams") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + Button { + selectedTeamId = team.id + Task { await listTeamMembers() } + } label: { + HStack { + Text(team.name) + Spacer() + if team.id == selectedTeamId { + Image(systemName: "checkmark") + } + } + } + } + } + + if !selectedTeamId.isEmpty { + Section("Team Members (\(selectedTeamId.prefix(8))...)") { + Button("Refresh Members") { + Task { await listTeamMembers() } + } + + ForEach(teamMembers, id: \.self) { userId in + Text(userId) + .font(.caption) + } + } + + Section("Team Actions") { + Button("Update Team Name") { + Task { await updateTeamName() } + } + .disabled(teamName.isEmpty) + } + } + } + .navigationTitle("Teams") + .onAppear { + Task { await listTeams() } + } + } + + func createTeam() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Creating team: \(teamName)") + let team = try await user.createTeam(displayName: teamName) + viewModel.log("Team created: \(team.id)", type: .success) + await listTeams() + } catch { + viewModel.log("Create team failed: \(error)", type: .error) + } + } + + func listTeams() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Listing teams...") + let teamsList = try await user.listTeams() + var results: [(id: String, name: String)] = [] + for team in teamsList { + let name = await team.displayName + results.append((id: team.id, name: name)) + } + teams = results + viewModel.log("Found \(teams.count) teams", type: .success) + } catch { + viewModel.log("List teams failed: \(error)", type: .error) + } + } + + func listTeamMembers() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.log("Team not found", type: .error) + return + } + viewModel.log("Listing team members...") + let members = try await team.listUsers() + teamMembers = members.map { $0.id } + viewModel.log("Found \(members.count) members", type: .success) + } catch { + viewModel.log("List members failed: \(error)", type: .error) + } + } + + func updateTeamName() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.log("Team not found", type: .error) + return + } + viewModel.log("Updating team name: \(teamName)") + try await team.update(displayName: teamName) + viewModel.log("Team updated!", type: .success) + await listTeams() + } catch { + viewModel.log("Update team failed: \(error)", type: .error) + } + } +} + +// MARK: - Server View + +struct ServerView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var displayName = "" + @State private var userId = "" + @State private var teamName = "" + @State private var teamId = "" + @State private var users: [(id: String, email: String?)] = [] + @State private var teams: [(id: String, name: String)] = [] + + var body: some View { + List { + Section("Create User") { + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + TextField("Display Name", text: $displayName) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + } + + Button("Create User") { + Task { await createUser() } + } + .disabled(email.isEmpty) + + Button("Create User (All Options)") { + Task { await createUserWithOptions() } + } + .disabled(email.isEmpty) + } + + Section("Users") { + Button("List Users") { + Task { await listUsers() } + } + + ForEach(users, id: \.id) { user in + Button { + userId = user.id + } label: { + HStack { + Text(user.email ?? "no email") + Spacer() + if user.id == userId { + Image(systemName: "checkmark") + } + } + } + } + } + + Section("User Operations") { + TextField("User ID", text: $userId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Get User") { + Task { await getUser() } + } + .disabled(userId.isEmpty) + + Button("Delete User") { + Task { await deleteUser() } + } + .disabled(userId.isEmpty) + + Button("Create Session (Impersonate)") { + Task { await createSession() } + } + .disabled(userId.isEmpty) + } + + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + } + + Button("Create Team") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("Teams") { + Button("List Teams") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + Button { + teamId = team.id + } label: { + HStack { + Text(team.name) + Spacer() + if team.id == teamId { + Image(systemName: "checkmark") + } + } + } + } + } + + Section("Team Operations") { + TextField("Team ID", text: $teamId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Add User to Team") { + Task { await addUserToTeam() } + } + .disabled(teamId.isEmpty || userId.isEmpty) + + Button("Remove User from Team") { + Task { await removeUserFromTeam() } + } + .disabled(teamId.isEmpty || userId.isEmpty) + + Button("List Team Users") { + Task { await listTeamUsers() } + } + .disabled(teamId.isEmpty) + + Button("Delete Team") { + Task { await deleteTeam() } + } + .disabled(teamId.isEmpty) + } + } + .navigationTitle("Server") + } + + func createUser() async { + do { + viewModel.log("Creating user: \(email)") + let user = try await viewModel.serverApp.createUser(email: email) + viewModel.log("User created: \(user.id)", type: .success) + userId = user.id + await listUsers() + } catch { + viewModel.log("Create user failed: \(error)", type: .error) + } + } + + func createUserWithOptions() async { + do { + viewModel.log("Creating user with options: \(email)") + let user = try await viewModel.serverApp.createUser( + email: email, + password: "TestPassword123!", + displayName: displayName.isEmpty ? nil : displayName, + primaryEmailVerified: true, + clientMetadata: ["source": "iOS-example"], + serverMetadata: ["created_via": "example-app"] + ) + viewModel.log("User created: \(user.id)", type: .success) + userId = user.id + await listUsers() + } catch { + viewModel.log("Create user failed: \(error)", type: .error) + } + } + + func listUsers() async { + do { + viewModel.log("Listing users...") + let result = try await viewModel.serverApp.listUsers(limit: 5) + var usersList: [(id: String, email: String?)] = [] + for user in result.items { + let email = await user.primaryEmail + usersList.append((id: user.id, email: email)) + } + users = usersList + viewModel.log("Found \(users.count) users", type: .success) + } catch { + viewModel.log("List users failed: \(error)", type: .error) + } + } + + func getUser() async { + do { + viewModel.log("Getting user: \(userId)") + let user = try await viewModel.serverApp.getUser(id: userId) + if let user = user { + let email = await user.primaryEmail + viewModel.log("User: \(email ?? "nil")", type: .success) + } else { + viewModel.log("User not found", type: .info) + } + } catch { + viewModel.log("Get user failed: \(error)", type: .error) + } + } + + func deleteUser() async { + do { + viewModel.log("Deleting user: \(userId)") + guard let user = try await viewModel.serverApp.getUser(id: userId) else { + viewModel.log("User not found", type: .error) + return + } + try await user.delete() + viewModel.log("User deleted!", type: .success) + userId = "" + await listUsers() + } catch { + viewModel.log("Delete user failed: \(error)", type: .error) + } + } + + func createSession() async { + do { + viewModel.log("Creating session for: \(userId)") + let tokens = try await viewModel.serverApp.createSession(userId: userId) + viewModel.log("Session created!", type: .success) + viewModel.log("Access token: \(tokens.accessToken.prefix(30))...", type: .info) + } catch { + viewModel.log("Create session failed: \(error)", type: .error) + } + } + + func createTeam() async { + do { + viewModel.log("Creating team: \(teamName)") + let team = try await viewModel.serverApp.createTeam(displayName: teamName) + viewModel.log("Team created: \(team.id)", type: .success) + teamId = team.id + await listTeams() + } catch { + viewModel.log("Create team failed: \(error)", type: .error) + } + } + + func listTeams() async { + do { + viewModel.log("Listing teams...") + let teamsList = try await viewModel.serverApp.listTeams() + var results: [(id: String, name: String)] = [] + for team in teamsList { + let name = await team.displayName + results.append((id: team.id, name: name)) + } + teams = results + viewModel.log("Found \(teams.count) teams", type: .success) + } catch { + viewModel.log("List teams failed: \(error)", type: .error) + } + } + + func addUserToTeam() async { + do { + viewModel.log("Adding user to team...") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + try await team.addUser(id: userId) + viewModel.log("User added to team!", type: .success) + } catch { + viewModel.log("Add user failed: \(error)", type: .error) + } + } + + func removeUserFromTeam() async { + do { + viewModel.log("Removing user from team...") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + try await team.removeUser(id: userId) + viewModel.log("User removed from team!", type: .success) + } catch { + viewModel.log("Remove user failed: \(error)", type: .error) + } + } + + func listTeamUsers() async { + do { + viewModel.log("Listing team users...") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + let users = try await team.listUsers() + viewModel.log("Found \(users.count) users", type: .success) + for user in users { + viewModel.log(" - \(user.id)", type: .info) + } + } catch { + viewModel.log("List team users failed: \(error)", type: .error) + } + } + + func deleteTeam() async { + do { + viewModel.log("Deleting team: \(teamId)") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + try await team.delete() + viewModel.log("Team deleted!", type: .success) + teamId = "" + await listTeams() + } catch { + viewModel.log("Delete team failed: \(error)", type: .error) + } + } +} + +#Preview { + ContentView() +} diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index 0f49aecfb9..c6c3fb9d3b 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -136,6 +136,34 @@ The following are browser-only and not exposed: - Cookie-based token storage - `redirectMethod` constructor option +## Examples + +Interactive example apps are available for testing all SDK functions: + +### macOS Example + +```bash +cd Examples/StackAuthMacOS +swift run +``` + +Features a sidebar-based UI for testing authentication, user management, teams, OAuth, tokens, and server-side operations. + +### iOS Example + +```bash +cd Examples/StackAuthiOS +open Package.swift # Opens in Xcode +``` + +Features a tab-based UI optimized for iOS with the same comprehensive SDK coverage. + +Both examples include: +- Configurable API endpoints +- Real-time operation logs +- Error testing scenarios (wrong password, unauthorized access, etc.) +- Client and server app operations + ## Testing Tests use Swift Testing framework against a running backend. diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json new file mode 100644 index 0000000000..e0f6f40512 --- /dev/null +++ b/sdks/implementations/swift/package.json @@ -0,0 +1,12 @@ +{ + "name": "@stackframe/swift-sdk", + "version": "0.0.0", + "private": true, + "description": "Stack Auth Swift SDK", + "scripts": { + "test": "swift test", + "build": "swift build", + "start:mac-example": "cd Examples/StackAuthMacOS && swift run", + "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/Package.swift'" + } +} From 1c2c584e3024463b79b8010c9ca7cec7b0562a2b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 09:12:29 -0800 Subject: [PATCH 06/17] Reduce error handling on failed email renders --- apps/backend/src/lib/email-queue-step.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 5875a62d8d..15a13bb8a5 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -403,7 +403,6 @@ async function renderTenancyEmails(workerId: string, tenancyId: string, group: E const result = await renderEmailsForTenancyBatched(requests); if (result.status === "error") { - captureError("email-rendering-failed", result.error); for (const row of rowsWithKnownCategory) { await markRenderError(row, result.error); } @@ -423,7 +422,6 @@ async function renderTenancyEmails(workerId: string, tenancyId: string, group: E const firstPassResult = await renderEmailsForTenancyBatched(firstPassRequests); if (firstPassResult.status === "error") { - captureError("email-rendering-failed", firstPassResult.error); for (const row of rowsWithUnknownCategory) { await markRenderError(row, firstPassResult.error); } @@ -462,7 +460,6 @@ async function renderTenancyEmails(workerId: string, tenancyId: string, group: E const secondPassResult = await renderEmailsForTenancyBatched(secondPassRequests); if (secondPassResult.status === "error") { - captureError("email-rendering-failed-second-pass", secondPassResult.error); for (const { row } of needsSecondPass) { await markRenderError(row, secondPassResult.error); } From 2f0d34db6cbecf010c0627481d591ff76365a1eb Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 11:51:53 -0800 Subject: [PATCH 07/17] Mute unenecessary feature warning error --- .../src/app/api/latest/check-feature-support/route.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/check-feature-support/route.tsx b/apps/backend/src/app/api/latest/check-feature-support/route.tsx index 4f40afcbf9..62f9c87e66 100644 --- a/apps/backend/src/app/api/latest/check-feature-support/route.tsx +++ b/apps/backend/src/app/api/latest/check-feature-support/route.tsx @@ -22,7 +22,11 @@ export const POST = createSmartRouteHandler({ body: yupString().defined(), }), handler: async (req) => { - captureError("check-feature-support", new StackAssertionError(`${req.auth?.user?.primaryEmail || "User"} tried to check support of unsupported feature: ${JSON.stringify(req.body, null, 2)}`, { req })); + const featureName = req.body?.feature_name; + const expectedUnsupportedFeatures = ["rsc-handler-signIn"]; + if (!expectedUnsupportedFeatures.includes(featureName)) { + captureError("check-feature-support", new StackAssertionError(`${req.auth?.user?.primaryEmail || "User"} tried to check support of unsupported feature: ${JSON.stringify(req.body, null, 2)}`, { req })); + } return { statusCode: 200, bodyType: "text", From 154d2a69224d73579cc796626950388c548d28cc Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Mon, 19 Jan 2026 12:35:43 -0800 Subject: [PATCH 08/17] fix sign in bug on dev (#1119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary by CodeRabbit * **Refactor** * Updated internal environment detection mechanism for OAuth flows. Insecure HTTP requests are now allowed when running outside of production environments, rather than only during testing scenarios. No changes to public APIs. ✏️ Tip: You can customize this high-level summary in your review settings. --- packages/stack-shared/src/interface/client-interface.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index d2a16342ea..7c4c027e5d 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -5,7 +5,6 @@ import { KnownError, KnownErrors } from '../known-errors'; import { inlineProductSchema } from '../schema-fields'; import { AccessToken, InternalSession, RefreshToken } from '../sessions'; import { generateSecureRandomString } from '../utils/crypto'; -import { getNodeEnvironment } from '../utils/env'; import { StackAssertionError, throwErr } from '../utils/errors'; import { globalVar } from '../utils/globals'; import { HTTP_METHODS, HttpMethod } from '../utils/http'; @@ -166,7 +165,8 @@ export class StackClientInterface { }; const clientAuthentication = oauth.ClientSecretPost(this.options.publishableClientKey); - const allowInsecure = getNodeEnvironment() === 'test' && tokenEndpoint.startsWith('http://'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const allowInsecure = (process.env.NODE_ENV?.includes("dev") || process.env.NODE_ENV === 'test') && tokenEndpoint.startsWith('http://'); const response = await this._networkRetryException(async () => { const rawResponse = await oauth.refreshTokenGrantRequest( @@ -1042,7 +1042,8 @@ export class StackClientInterface { }; const clientAuthentication = oauth.ClientSecretPost(this.options.publishableClientKey); // Allow insecure HTTP requests only in test environment (for localhost testing) - const allowInsecure = getNodeEnvironment() === 'test' && tokenEndpoint.startsWith('http://'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const allowInsecure = (process.env.NODE_ENV?.includes("dev") || process.env.NODE_ENV === 'test') && tokenEndpoint.startsWith('http://'); let params: URLSearchParams; try { From 0220219363ff72d61e769ddebbb3d5714991ca90 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 14:38:29 -0800 Subject: [PATCH 09/17] More Swift SDK fixes --- .../StackAuthMacOS/StackAuthMacOSApp.swift | 63 +- .../contents.xcworkspacedata | 7 + .../swift/Examples/StackAuthiOS/Package.swift | 21 - .../swift/Examples/StackAuthiOS/README.md | 172 +- .../StackAuthiOS.xcodeproj/project.pbxproj | 346 ++++ .../StackAuthiOS/StackAuthiOSApp.swift | 1819 ++++++++++++----- sdks/implementations/swift/package.json | 2 +- 7 files changed, 1837 insertions(+), 593 deletions(-) create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata delete mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/Package.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index 3af7509d25..23b9624d79 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import AuthenticationServices import StackAuth @main @@ -1224,15 +1225,25 @@ struct ContactChannelsView: View { } } +// MARK: - OAuth Presentation Context Provider + +class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return NSApplication.shared.windows.first ?? ASPresentationAnchor() + } +} + // MARK: - OAuth View struct OAuthView: View { @Bindable var viewModel: SDKTestViewModel @State private var provider = "google" + @State private var isSigningIn = false + private let presentationProvider = MacOSPresentationContextProvider() var body: some View { Form { - Section("OAuth URL Generation") { + Section("Sign In with OAuth") { TextField("Provider", text: $provider) HStack { @@ -1241,15 +1252,65 @@ struct OAuthView: View { Button("microsoft") { provider = "microsoft" } } + Button("signInWithOAuth(provider: \"\(provider)\")") { + Task { await signInWithOAuth() } + } + .disabled(isSigningIn) + + if isSigningIn { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Waiting for OAuth...") + .foregroundStyle(.secondary) + } + } + } + + Section("OAuth URL Generation (Manual)") { Button("getOAuthUrl(provider: \"\(provider)\")") { Task { await getOAuthUrl() } } + + Text("Returns URL, state, and codeVerifier for manual OAuth handling") + .font(.caption) + .foregroundStyle(.secondary) } } .formStyle(.grouped) .navigationTitle("OAuth") } + func signInWithOAuth() async { + let params = "provider: \"\(provider)\"" + viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params) + isSigningIn = true + + do { + try await viewModel.clientApp.signInWithOAuth( + provider: provider, + presentationContextProvider: presentationProvider + ) + viewModel.logCall( + "signInWithOAuth(provider:)", + params: params, + result: "Success! User signed in via OAuth." + ) + // Fetch user to show details + if let user = try await viewModel.clientApp.getUser() { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "getUser() after OAuth", + result: formatObject("CurrentUser", dict) + ) + } + } catch { + viewModel.logCall("signInWithOAuth(provider:)", params: params, error: error) + } + + isSigningIn = false + } + func getOAuthUrl() async { let params = "provider: \"\(provider)\"" viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift b/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift deleted file mode 100644 index ffda99741e..0000000000 --- a/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift +++ /dev/null @@ -1,21 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "StackAuthiOS", - platforms: [ - .iOS(.v17) - ], - dependencies: [ - .package(name: "StackAuth", path: "../..") - ], - targets: [ - .executableTarget( - name: "StackAuthiOS", - dependencies: [ - .product(name: "StackAuth", package: "StackAuth") - ], - path: "StackAuthiOS" - ) - ] -) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md index 171cbf0d3f..6b425829f0 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/README.md +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -1,107 +1,103 @@ # Stack Auth iOS Example -A comprehensive iOS SwiftUI application for testing all Stack Auth SDK functions interactively. +An interactive iOS application for testing all Stack Auth Swift SDK functions. ## Prerequisites -- iOS 17.0+ -- Swift 5.9+ -- Xcode 15.0+ -- A running Stack Auth backend accessible from the iOS device/simulator +- Xcode 15.0 or later +- iOS 17.0+ Simulator or device +- Running Stack Auth backend (default: `http://localhost:8102`) ## Running the Example -1. Start the Stack Auth backend: - ```bash - cd /path/to/stack-2 - pnpm run dev - ``` +### Option 1: Xcode -2. Open in Xcode: +1. Open the project in Xcode: ```bash - cd Examples/StackAuthiOS - open Package.swift + open StackAuthiOS.xcodeproj ``` -3. Select an iOS simulator or device and run. +2. Select an iOS Simulator (e.g., "iPhone 17 Pro") as the destination + +3. Press ⌘R to build and run + +### Option 2: Command Line + +```bash +# Build +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -**Note**: When testing on a physical device, update the base URL in Settings to point to your machine's IP address (e.g., `http://192.168.1.x:8102`). +# Build and run (opens simulator) +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' run +``` ## Features -The example app uses a tab-based navigation with the following sections: - -### Auth Tab -- Sign up with email/password -- Sign in with credentials -- Sign in with wrong password (error testing) -- Sign out -- Get current user -- Get user (or throw) -- Generate OAuth URLs (Google, GitHub, Microsoft) - -### User Tab -- Set display name -- Update client metadata -- Update password (correct and wrong old password) -- Get access/refresh tokens -- Get auth headers -- Get partial user from token -- List contact channels - -### Teams Tab -- Create team -- List user's teams -- Select and view team details -- List team members -- Update team name - -### Server Tab -- **Users** - - Create user (basic and with all options) - - List users - - Get/delete user by ID - - Create session (impersonation) - -- **Teams** - - Create team - - List all teams - - Add/remove users from teams - - List team users - - Delete team - -### Settings Tab -- Configure API base URL -- Configure project ID and API keys -- View operation logs - -## Default Configuration - -The example is pre-configured for local development: -- Base URL: `http://localhost:8102` -- Project ID: `internal` -- Publishable Key: `this-publishable-client-key-is-for-local-development-only` -- Secret Key: `this-secret-server-key-is-for-local-development-only` - -## Simulator Network Notes - -When running in the iOS Simulator, `localhost` will connect to your Mac's localhost. For physical devices, use your Mac's local IP address. +The app uses a tab-based interface optimized for mobile: + +- **Settings**: Configure API endpoint, project ID, and keys +- **Auth**: Sign up, sign in, sign out, get current user +- **User**: Update display name, metadata, view tokens +- **Teams**: Create, list, and manage teams +- **Logs**: View all SDK calls with full details (tap for more, long-press to copy) + +Additional functions are accessible via navigation links in Settings: +- Contact Channels +- OAuth URL generation +- Token operations +- Server Users (admin) +- Server Teams (admin) +- Sessions (impersonation) ## SDK Functions Covered -| Category | Functions | -|----------|-----------| -| Auth | signUpWithCredential, signInWithCredential, signOut, getUser, getOAuthUrl | -| User | setDisplayName, update (metadata), updatePassword, getAccessToken, getRefreshToken, getAuthHeaders, getPartialUser | -| Teams | createTeam, listTeams, getTeam, listUsers (team members), update | -| Contact | listContactChannels | -| Server Users | createUser, listUsers, getUser, delete, createSession | -| Server Teams | createTeam, listTeams, getTeam, addUser, removeUser, listUsers, delete | -| Errors | EmailPasswordMismatchError, UserNotSignedInError, PasswordConfirmationMismatchError | - -## Testing Edge Cases - -The app includes buttons specifically for testing error scenarios: -- "Sign In (Wrong Password)" - triggers EmailPasswordMismatchError -- "Get User (or throw)" - triggers UserNotSignedInError when not signed in -- "Update (Wrong Old Password)" - triggers PasswordConfirmationMismatchError +### Client App +- `signUpWithCredential(email:password:)` +- `signInWithCredential(email:password:)` +- `signOut()` +- `getUser()` / `getUser(or:)` +- `getAccessToken()` / `getRefreshToken()` +- `getAuthHeaders()` +- `getOAuthUrl(provider:)` + +### Current User +- `setDisplayName(_:)` +- `update(clientMetadata:)` +- `listTeams()` / `getTeam(id:)` +- `createTeam(displayName:)` +- `listContactChannels()` + +### Server App +- `createUser(email:password:...)` +- `listUsers(limit:)` +- `getUser(id:)` +- `createTeam(displayName:)` +- `listTeams()` +- `createSession(userId:)` + +## Logging + +The Logs tab shows all SDK activity in real-time: +- **Green checkmark**: Successful calls with full response data +- **Red X**: Errors with details +- **Blue info**: In-progress calls + +Tap any log entry to see full details. Long-press to copy to clipboard. + +## Network Configuration + +For iOS Simulator to connect to your local backend: + +1. The default `localhost:8102` should work in the simulator +2. For a real device, use your computer's local IP address instead + +## Troubleshooting + +### "Could not connect to server" +- Ensure your Stack Auth backend is running +- Check the Base URL in Settings tab +- For real devices, use your computer's IP instead of localhost + +### Build errors +- Make sure you have Xcode 15+ installed +- Try cleaning: Product → Clean Build Folder (⇧⌘K) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..851c58c6d0 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj @@ -0,0 +1,346 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + E01234560001 /* StackAuthiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E01234560002; }; + E01234560003 /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = E01234560004; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + E01234560002 /* StackAuthiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackAuthiOSApp.swift; sourceTree = ""; }; + E01234560005 /* StackAuthiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackAuthiOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E01234560006 /* StackAuth */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = StackAuth; path = ../..; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E01234560007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E01234560003 /* StackAuth in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E01234560008 = { + isa = PBXGroup; + children = ( + E01234560009 /* StackAuthiOS */, + E0123456000A /* Products */, + E0123456000B /* Packages */, + ); + sourceTree = ""; + }; + E01234560009 /* StackAuthiOS */ = { + isa = PBXGroup; + children = ( + E01234560002 /* StackAuthiOSApp.swift */, + ); + path = StackAuthiOS; + sourceTree = ""; + }; + E0123456000A /* Products */ = { + isa = PBXGroup; + children = ( + E01234560005 /* StackAuthiOS.app */, + ); + name = Products; + sourceTree = ""; + }; + E0123456000B /* Packages */ = { + isa = PBXGroup; + children = ( + E01234560006 /* StackAuth */, + ); + name = Packages; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E0123456000C /* StackAuthiOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = E0123456000D; + buildPhases = ( + E0123456000E /* Sources */, + E01234560007 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StackAuthiOS; + packageProductDependencies = ( + E01234560004 /* StackAuth */, + ); + productName = StackAuthiOS; + productReference = E01234560005 /* StackAuthiOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E0123456000F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + E0123456000C = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = E01234560010; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E01234560008; + packageReferences = ( + E01234560011 /* XCLocalSwiftPackageReference "../.." */, + ); + productRefGroup = E0123456000A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E0123456000C /* StackAuthiOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + E0123456000E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E01234560001 /* StackAuthiOSApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + E01234560012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E01234560013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E01234560014 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stackauth.example.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E01234560015 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stackauth.example.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E0123456000D /* Build configuration list for PBXNativeTarget "StackAuthiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E01234560014 /* Debug */, + E01234560015 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E01234560010 /* Build configuration list for PBXProject "StackAuthiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E01234560012 /* Debug */, + E01234560013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + E01234560011 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../.."; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E01234560004 /* StackAuth */ = { + isa = XCSwiftPackageProductDependency; + productName = StackAuth; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E0123456000F /* Project object */; +} diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift index 1e1fefbe22..e6d381b2bb 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -1,4 +1,6 @@ import SwiftUI +import UIKit +import AuthenticationServices import StackAuth @main @@ -10,51 +12,343 @@ struct StackAuthiOSApp: App { } } +// MARK: - iOS OAuth Presentation Context Provider + +class iOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first else { + return ASPresentationAnchor() + } + return window + } +} + // MARK: - Main Content View struct ContentView: View { @State private var viewModel = SDKTestViewModel() + @State private var selectedTab = 0 + @State private var lastSeenLogCount = 0 + + var unreadLogCount: Int { + max(0, viewModel.logs.count - lastSeenLogCount) + } var body: some View { - TabView { - NavigationStack { - AuthenticationView(viewModel: viewModel) + ZStack { + TabView(selection: $selectedTab) { + NavigationStack { + SettingsView(viewModel: viewModel) + } + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(0) + + NavigationStack { + AuthenticationView(viewModel: viewModel) + } + .tabItem { + Label("Auth", systemImage: "person.badge.key") + } + .tag(1) + + NavigationStack { + UserManagementView(viewModel: viewModel) + } + .tabItem { + Label("User", systemImage: "person.crop.circle") + } + .tag(2) + + NavigationStack { + TeamsView(viewModel: viewModel) + } + .tabItem { + Label("Teams", systemImage: "person.3") + } + .tag(3) + + NavigationStack { + LogsView(viewModel: viewModel) + } + .tabItem { + Label("Logs", systemImage: "list.bullet.rectangle") + } + .badge(unreadLogCount > 0 ? unreadLogCount : 0) + .tag(4) } - .tabItem { - Label("Auth", systemImage: "person.badge.key") + .onChange(of: selectedTab) { _, newTab in + if newTab == 4 { + // User switched to Logs tab, mark all as read + lastSeenLogCount = viewModel.logs.count + } } - NavigationStack { - UserView(viewModel: viewModel) + // Toast notification overlay + LogToastView(viewModel: viewModel, selectedTab: $selectedTab) + } + } +} + +// MARK: - Log Toast View + +struct LogToastView: View { + @Bindable var viewModel: SDKTestViewModel + @Binding var selectedTab: Int + @State private var showToast = false + @State private var toastEntry: LogEntry? + @State private var lastLogId: UUID? + + var body: some View { + VStack { + if showToast, let entry = toastEntry, selectedTab != 4 { + HStack(spacing: 12) { + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + + VStack(alignment: .leading, spacing: 2) { + if let function = entry.function { + Text(function) + .font(.caption.bold()) + .lineLimit(1) + } + Text(entry.message) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + Button { + selectedTab = 4 + withAnimation { + showToast = false + } + } label: { + Text("View") + .font(.caption.bold()) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.small) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .shadow(radius: 8) + .padding(.horizontal) + .transition(.move(edge: .top).combined(with: .opacity)) + .onTapGesture { + selectedTab = 4 + withAnimation { + showToast = false + } + } } - .tabItem { - Label("User", systemImage: "person.crop.circle") + Spacer() + } + .padding(.top, 8) + .onChange(of: viewModel.logs.first?.id) { _, newId in + guard let newId = newId, newId != lastLogId, selectedTab != 4 else { return } + lastLogId = newId + toastEntry = viewModel.logs.first + withAnimation(.spring(duration: 0.3)) { + showToast = true } - - NavigationStack { - TeamsView(viewModel: viewModel) + // Auto-hide after 3 seconds + Task { + try? await Task.sleep(for: .seconds(3)) + withAnimation { + if toastEntry?.id == newId { + showToast = false + } + } + } + } + } +} + +// MARK: - Logs View + +struct LogsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var selectedLogId: UUID? + + var body: some View { + VStack(spacing: 0) { + if viewModel.logs.isEmpty { + VStack { + Spacer() + Image(systemName: "list.bullet.rectangle") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text("No activity yet") + .foregroundStyle(.secondary) + Text("Use the SDK from other tabs to see logs here") + .font(.caption) + .foregroundStyle(.tertiary) + Spacer() + } + } else { + List(viewModel.logs, selection: $selectedLogId) { entry in + LogEntryView(entry: entry) + .id(entry.id) + .contextMenu { + Button { + UIPasteboard.general.string = entry.message + } label: { + Label("Copy Message", systemImage: "doc.on.doc") + } + Button { + UIPasteboard.general.string = entry.fullDescription + } label: { + Label("Copy Full Details", systemImage: "doc.on.doc.fill") + } + } + } + .listStyle(.plain) } - .tabItem { - Label("Teams", systemImage: "person.3") + } + .navigationTitle("SDK Logs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack { + Text("\(viewModel.logs.count)") + .foregroundStyle(.secondary) + .font(.caption) + Button("Clear") { + viewModel.clearLogs() + } + } } - - NavigationStack { - ServerView(viewModel: viewModel) + } + .sheet(item: $selectedLogId) { id in + if let entry = viewModel.logs.first(where: { $0.id == id }) { + LogDetailSheet(entry: entry) } - .tabItem { - Label("Server", systemImage: "server.rack") + } + } +} + +extension UUID: @retroactive Identifiable { + public var id: UUID { self } +} + +struct LogDetailSheet: View { + let entry: LogEntry + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + Text(entry.type.rawValue) + .font(.headline) + .foregroundStyle(entry.type.color) + Spacer() + Text(entry.timestamp, style: .time) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let function = entry.function { + VStack(alignment: .leading, spacing: 4) { + Text("Function") + .font(.caption) + .foregroundStyle(.secondary) + Text(function) + .font(.system(.body, design: .monospaced)) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("Details") + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.fullDescription) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() } - - NavigationStack { - SettingsView(viewModel: viewModel) + .navigationTitle("Log Entry") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + UIPasteboard.general.string = entry.fullDescription + } label: { + Image(systemName: "doc.on.doc") + } + } } - .tabItem { - Label("Settings", systemImage: "gear") + } + } +} + +struct LogEntryView: View { + let entry: LogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + if let function = entry.function { + Text(function) + .font(.system(.caption, design: .monospaced).bold()) + .foregroundStyle(.primary) + } + + Text(entry.message) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(entry.type.color) + .lineLimit(3) + + Text(entry.timestamp, style: .time) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer() } } + .padding(.vertical, 4) } } +// MARK: - Test Sections + +enum TestSection: String, CaseIterable, Identifiable { + case settings + case authentication + case userManagement + case teams + case contactChannels + case oauth + case tokens + case serverUsers + case serverTeams + case sessions + + var id: String { rawValue } +} + // MARK: - View Model @Observable @@ -66,7 +360,9 @@ class SDKTestViewModel { var secretServerKey = "this-secret-server-key-is-for-local-development-only" // State + var selectedSection: TestSection = .settings var logs: [LogEntry] = [] + var isLoading = false // Apps (lazy initialized) private var _clientApp: StackClientApp? @@ -100,14 +396,54 @@ class SDKTestViewModel { func resetApps() { _clientApp = nil _serverApp = nil - log("Apps reset with new configuration", type: .info) + logCall("resetApps()", result: "Apps reset with new configuration") + } + + // Enhanced logging + func logCall(_ function: String, params: String? = nil, result: String) { + let message = result + let details = params.map { "Parameters:\n\($0)\n\nResult:\n\(result)" } ?? "Result:\n\(result)" + let entry = LogEntry( + function: function, + message: message, + details: details, + type: .success, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + func logCall(_ function: String, params: String? = nil, error: Error) { + let errorStr = String(describing: error) + let message = errorStr + let details = params.map { "Parameters:\n\($0)\n\nError:\n\(errorStr)" } ?? "Error:\n\(errorStr)" + let entry = LogEntry( + function: function, + message: message, + details: details, + type: .error, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() } - func log(_ message: String, type: LogType = .info) { - let entry = LogEntry(message: message, type: type, timestamp: Date()) + func logInfo(_ function: String, message: String, details: String? = nil) { + let entry = LogEntry( + function: function, + message: message, + details: details ?? message, + type: .info, + timestamp: Date() + ) logs.insert(entry, at: 0) - if logs.count > 50 { - logs.removeLast() + trimLogs() + } + + private func trimLogs() { + if logs.count > 200 { + logs.removeLast(logs.count - 200) } } @@ -118,13 +454,31 @@ class SDKTestViewModel { struct LogEntry: Identifiable { let id = UUID() + let function: String? let message: String + let details: String? let type: LogType let timestamp: Date + + var fullDescription: String { + var parts: [String] = [] + parts.append("Time: \(timestamp.formatted(date: .omitted, time: .standard))") + if let function = function { + parts.append("Function: \(function)") + } + parts.append("Status: \(type.rawValue)") + parts.append("Message: \(message)") + if let details = details { + parts.append("\nDetails:\n\(details)") + } + return parts.joined(separator: "\n") + } } -enum LogType { - case info, success, error +enum LogType: String { + case info = "INFO" + case success = "SUCCESS" + case error = "ERROR" var color: Color { switch self { @@ -133,6 +487,174 @@ enum LogType { case .error: return .red } } + + var icon: String { + switch self { + case .info: return "info.circle" + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + } + } +} + +// MARK: - Object Serialization Helpers + +func formatValue(_ value: Any?, indent: Int = 0) -> String { + let spaces = String(repeating: " ", count: indent) + + guard let value = value else { return "nil" } + + switch value { + case let str as String: + return "\"\(str)\"" + case let bool as Bool: + return bool ? "true" : "false" + case let num as NSNumber: + return "\(num)" + case let date as Date: + return "\"\(date.formatted())\"" + case let url as URL: + return "\"\(url.absoluteString)\"" + case let dict as [String: Any]: + if dict.isEmpty { return "{}" } + var lines = ["{"] + for (key, val) in dict.sorted(by: { $0.key < $1.key }) { + lines.append("\(spaces) \(key): \(formatValue(val, indent: indent + 1))") + } + lines.append("\(spaces)}") + return lines.joined(separator: "\n") + case let arr as [Any]: + if arr.isEmpty { return "[]" } + var lines = ["["] + for item in arr { + lines.append("\(spaces) \(formatValue(item, indent: indent + 1)),") + } + lines.append("\(spaces)]") + return lines.joined(separator: "\n") + default: + return String(describing: value) + } +} + +func serializeCurrentUser(_ user: CurrentUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = await user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + dict["isAnonymous"] = await user.isAnonymous + dict["isRestricted"] = await user.isRestricted + if let reason = await user.restrictedReason { + dict["restrictedReason"] = String(describing: reason) + } + let providers = await user.oauthProviders + if !providers.isEmpty { + dict["oauthProviders"] = providers.map { ["id": $0.id] } + } + if let team = await user.selectedTeam { + dict["selectedTeam"] = ["id": team.id, "displayName": await team.displayName] + } + return dict +} + +func serializeServerUser(_ user: ServerUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + if let lastActiveAt = await user.lastActiveAt { + dict["lastActiveAt"] = lastActiveAt.formatted() + } + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["serverMetadata"] = await user.serverMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + return dict +} + +func serializeTeam(_ team: Team) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + return dict +} + +func serializeServerTeam(_ team: ServerTeam) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + dict["serverMetadata"] = await team.serverMetadata + dict["createdAt"] = await team.createdAt.formatted() + return dict +} + +func serializeContactChannel(_ channel: ContactChannel) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = channel.id + dict["type"] = await channel.type + dict["value"] = await channel.value + dict["isPrimary"] = await channel.isPrimary + dict["isVerified"] = await channel.isVerified + dict["usedForAuth"] = await channel.usedForAuth + return dict +} + +func serializeTeamUser(_ user: TeamUser) -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["teamProfile"] = [ + "displayName": user.teamProfile.displayName as Any, + "profileImageUrl": user.teamProfile.profileImageUrl as Any + ] + return dict +} + +func formatObject(_ name: String, _ dict: [String: Any]) -> String { + var lines = ["\(name) {"] + for (key, value) in dict.sorted(by: { $0.key < $1.key }) { + lines.append(" \(key): \(formatValue(value, indent: 1))") + } + lines.append("}") + return lines.joined(separator: "\n") +} + +func formatObjectArray(_ name: String, _ items: [[String: Any]]) -> String { + if items.isEmpty { + return "\(name) []" + } + var lines = ["\(name) ["] + for (index, item) in items.enumerated() { + lines.append(" [\(index)] {") + for (key, value) in item.sorted(by: { $0.key < $1.key }) { + lines.append(" \(key): \(formatValue(value, indent: 2))") + } + lines.append(" }") + } + lines.append("]") + lines.append("Total: \(items.count) items") + return lines.joined(separator: "\n") } // MARK: - Settings View @@ -141,43 +663,65 @@ struct SettingsView: View { @Bindable var viewModel: SDKTestViewModel var body: some View { - List { + Form { Section("API Configuration") { TextField("Base URL", text: $viewModel.baseUrl) .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .keyboardType(.URL) TextField("Project ID", text: $viewModel.projectId) .textInputAutocapitalization(.never) - .autocorrectionDisabled() TextField("Publishable Client Key", text: $viewModel.publishableClientKey) .textInputAutocapitalization(.never) - .autocorrectionDisabled() SecureField("Secret Server Key", text: $viewModel.secretServerKey) Button("Apply Configuration") { viewModel.resetApps() } + .buttonStyle(.borderedProminent) } - Section("Logs (\(viewModel.logs.count))") { - Button("Clear Logs") { - viewModel.clearLogs() + Section("Quick Actions") { + Button("Test Connection") { + Task { await testConnection() } } - - ForEach(viewModel.logs) { entry in - VStack(alignment: .leading) { - Text(entry.timestamp, style: .time) - .font(.caption2) - .foregroundStyle(.secondary) - Text(entry.message) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(entry.type.color) - } + } + + Section("More Functions") { + NavigationLink("Contact Channels") { + ContactChannelsView(viewModel: viewModel) + } + NavigationLink("OAuth") { + OAuthView(viewModel: viewModel) + } + NavigationLink("Tokens") { + TokensView(viewModel: viewModel) + } + NavigationLink("Server Users") { + ServerUsersView(viewModel: viewModel) + } + NavigationLink("Server Teams") { + ServerTeamsView(viewModel: viewModel) + } + NavigationLink("Sessions") { + SessionsView(viewModel: viewModel) } } } .navigationTitle("Settings") } + + func testConnection() async { + viewModel.logInfo("testConnection()", message: "Testing connection to \(viewModel.baseUrl)...") + do { + let project = try await viewModel.clientApp.getProject() + viewModel.logCall( + "getProject()", + result: "Connected! Project ID: \(project.id)" + ) + } catch { + viewModel.logCall("getProject()", error: error) + } + } } // MARK: - Authentication View @@ -186,193 +730,191 @@ struct AuthenticationView: View { @Bindable var viewModel: SDKTestViewModel @State private var email = "" @State private var password = "TestPassword123!" - @State private var currentUserEmail: String? - @State private var currentUserId: String? + @State private var currentUser: String? var body: some View { - List { + Form { Section("Credentials") { TextField("Email", text: $email) .textInputAutocapitalization(.never) - .autocorrectionDisabled() .keyboardType(.emailAddress) SecureField("Password", text: $password) Button("Generate Random Email") { - email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") } } - Section("Actions") { - Button("Sign Up") { + Section("Sign Up") { + Button("signUpWithCredential(email, password)") { Task { await signUp() } } .disabled(email.isEmpty || password.isEmpty) - - Button("Sign In") { + } + + Section("Sign In") { + Button("signInWithCredential(email, password)") { Task { await signIn() } } .disabled(email.isEmpty || password.isEmpty) - Button("Sign In (Wrong Password)") { + Button("signInWithCredential(email, WRONG_PASSWORD)") { Task { await signInWrongPassword() } } .disabled(email.isEmpty) - - Button("Sign Out") { + } + + Section("Sign Out") { + Button("signOut()") { Task { await signOut() } } } Section("Current User") { - Button("Refresh User") { + Button("getUser()") { Task { await getUser() } } - if let email = currentUserEmail, let id = currentUserId { - Text("Email: \(email)") - Text("ID: \(id)") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text("Not signed in") - .foregroundStyle(.secondary) - } - } - - Section("OAuth") { - Button("Get Google OAuth URL") { - Task { await getOAuthUrl("google") } - } - Button("Get GitHub OAuth URL") { - Task { await getOAuthUrl("github") } - } - Button("Get Microsoft OAuth URL") { - Task { await getOAuthUrl("microsoft") } - } - } - - Section("Error Testing") { - Button("Get User (or throw)") { + Button("getUser(or: .throw)") { Task { await getUserOrThrow() } } + + if let user = currentUser { + Text(user) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } } } .navigationTitle("Authentication") - .onAppear { - Task { await getUser() } - } } func signUp() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params) + do { - viewModel.log("Signing up: \(email)") try await viewModel.clientApp.signUpWithCredential(email: email, password: password) - viewModel.log("Sign up successful!", type: .success) + viewModel.logCall( + "signUpWithCredential(email, password)", + params: params, + result: "Success! User signed up." + ) await getUser() } catch { - viewModel.log("Sign up failed: \(error)", type: .error) + viewModel.logCall("signUpWithCredential(email, password)", params: params, error: error) } } func signIn() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signInWithCredential()", message: "Calling...", details: params) + do { - viewModel.log("Signing in: \(email)") try await viewModel.clientApp.signInWithCredential(email: email, password: password) - viewModel.log("Sign in successful!", type: .success) + viewModel.logCall( + "signInWithCredential(email, password)", + params: params, + result: "Success! User signed in." + ) await getUser() } catch { - viewModel.log("Sign in failed: \(error)", type: .error) + viewModel.logCall("signInWithCredential(email, password)", params: params, error: error) } } func signInWrongPassword() async { + let params = "email: \"\(email)\"\npassword: \"WrongPassword!\"" + viewModel.logInfo("signInWithCredential()", message: "Calling with wrong password...", details: params) + do { - viewModel.log("Signing in with wrong password...") try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") - viewModel.log("Sign in succeeded (unexpected)", type: .error) + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Unexpected success (should have failed)" + ) } catch let error as EmailPasswordMismatchError { - viewModel.log("Got EmailPasswordMismatchError: \(error.message)", type: .success) + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) } catch { - viewModel.log("Unexpected error: \(error)", type: .error) + viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error) } } func signOut() async { + viewModel.logInfo("signOut()", message: "Calling...") + do { - viewModel.log("Signing out...") try await viewModel.clientApp.signOut() - viewModel.log("Sign out successful!", type: .success) - currentUserEmail = nil - currentUserId = nil + viewModel.logCall("signOut()", result: "Success! User signed out.") + currentUser = nil } catch { - viewModel.log("Sign out failed: \(error)", type: .error) + viewModel.logCall("signOut()", error: error) } } func getUser() async { + viewModel.logInfo("getUser()", message: "Calling...") + do { let user = try await viewModel.clientApp.getUser() if let user = user { - currentUserEmail = await user.primaryEmail - currentUserId = await user.id - viewModel.log("Got user: \(currentUserEmail ?? "nil")", type: .success) + let dict = await serializeCurrentUser(user) + currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")" + viewModel.logCall( + "getUser()", + result: formatObject("CurrentUser", dict) + ) } else { - currentUserEmail = nil - currentUserId = nil - viewModel.log("No user signed in", type: .info) + currentUser = nil + viewModel.logCall("getUser()", result: "nil (no user signed in)") } } catch { - viewModel.log("Get user failed: \(error)", type: .error) + viewModel.logCall("getUser()", error: error) } } func getUserOrThrow() async { + viewModel.logInfo("getUser(or: .throw)", message: "Calling...") + do { - viewModel.log("Getting user (or throw)...") let user = try await viewModel.clientApp.getUser(or: .throw) if let user = user { - let email = await user.primaryEmail - viewModel.log("Got user: \(email ?? "nil")", type: .success) + let dict = await serializeCurrentUser(user) + viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict)) } else { - viewModel.log("No user (unexpected with .throw)", type: .error) + viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)") } } catch let error as UserNotSignedInError { - viewModel.log("Got UserNotSignedInError: \(error.message)", type: .success) - } catch { - viewModel.log("Unexpected error: \(error)", type: .error) - } - } - - func getOAuthUrl(_ provider: String) async { - do { - viewModel.log("Getting OAuth URL for \(provider)...") - let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) - viewModel.log("URL: \(result.url)", type: .success) - viewModel.log("State: \(result.state.prefix(20))...", type: .info) + viewModel.logCall( + "getUser(or: .throw)", + result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)" + ) } catch { - viewModel.log("Get OAuth URL failed: \(error)", type: .error) + viewModel.logCall("getUser(or: .throw)", error: error) } } } -// MARK: - User View +// MARK: - User Management View -struct UserView: View { +struct UserManagementView: View { @Bindable var viewModel: SDKTestViewModel @State private var displayName = "" @State private var metadataKey = "theme" @State private var metadataValue = "dark" - @State private var oldPassword = "TestPassword123!" - @State private var newPassword = "NewPassword456!" - @State private var channels: [(id: String, value: String, isPrimary: Bool)] = [] var body: some View { - List { + Form { Section("Display Name") { TextField("Display Name", text: $displayName) - Button("Set Display Name") { + Button("user.setDisplayName(displayName)") { Task { await setDisplayName() } } .disabled(displayName.isEmpty) @@ -382,169 +924,108 @@ struct UserView: View { TextField("Key", text: $metadataKey) TextField("Value", text: $metadataValue) - Button("Update Metadata") { + Button("user.update(clientMetadata: {key: value})") { Task { await updateMetadata() } } } - Section("Password") { - SecureField("Old Password", text: $oldPassword) - SecureField("New Password", text: $newPassword) - - Button("Update Password") { - Task { await updatePassword() } - } - - Button("Update (Wrong Old Password)") { - Task { await updatePasswordWrong() } - } - } - - Section("Tokens") { - Button("Get Access Token") { + Section("Token Info") { + Button("getAccessToken()") { Task { await getAccessToken() } } - Button("Get Refresh Token") { + + Button("getRefreshToken()") { Task { await getRefreshToken() } } - Button("Get Auth Headers") { - Task { await getAuthHeaders() } - } - Button("Get Partial User") { - Task { await getPartialUser() } - } - } - - Section("Contact Channels") { - Button("List Contact Channels") { - Task { await listChannels() } - } - ForEach(channels, id: \.id) { channel in - HStack { - Text(channel.value) - Spacer() - if channel.isPrimary { - Text("Primary") - .font(.caption) - .foregroundStyle(.blue) - } - } + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } } } } - .navigationTitle("User") + .navigationTitle("User Management") } func setDisplayName() async { + let params = "displayName: \"\(displayName)\"" + viewModel.logInfo("setDisplayName()", message: "Calling...", details: params) + do { guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) + viewModel.logCall("setDisplayName()", result: "Error: No user signed in") return } - viewModel.log("Setting display name: \(displayName)") try await user.setDisplayName(displayName) - viewModel.log("Display name set!", type: .success) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.setDisplayName(displayName)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) } catch { - viewModel.log("Set display name failed: \(error)", type: .error) + viewModel.logCall("user.setDisplayName(displayName)", params: params, error: error) } } func updateMetadata() async { + let params = "clientMetadata: {\"\(metadataKey)\": \"\(metadataValue)\"}" + viewModel.logInfo("update(clientMetadata:)", message: "Calling...", details: params) + do { guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) + viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in") return } - viewModel.log("Updating metadata: \(metadataKey)=\(metadataValue)") try await user.update(clientMetadata: [metadataKey: metadataValue]) - viewModel.log("Metadata updated!", type: .success) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.update(clientMetadata:)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) } catch { - viewModel.log("Update metadata failed: \(error)", type: .error) - } - } - - func updatePassword() async { - do { - guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) - return - } - viewModel.log("Updating password...") - try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword) - viewModel.log("Password updated!", type: .success) - } catch { - viewModel.log("Update password failed: \(error)", type: .error) - } - } - - func updatePasswordWrong() async { - do { - guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) - return - } - viewModel.log("Updating password with wrong old...") - try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword) - viewModel.log("Password updated (unexpected)", type: .error) - } catch let error as PasswordConfirmationMismatchError { - viewModel.log("Got PasswordConfirmationMismatchError", type: .success) - } catch { - viewModel.log("Unexpected error: \(error)", type: .error) + viewModel.logCall("user.update(clientMetadata:)", params: params, error: error) } } func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + let token = await viewModel.clientApp.getAccessToken() if let token = token { - viewModel.log("Access token: \(token.prefix(40))...", type: .success) + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)" + ) } else { - viewModel.log("No access token", type: .info) + viewModel.logCall("getAccessToken()", result: "nil (not signed in)") } } func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + let token = await viewModel.clientApp.getRefreshToken() if let token = token { - viewModel.log("Refresh token: \(token.prefix(20))...", type: .success) + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token (\(token.count) chars):\n\(token)" + ) } else { - viewModel.log("No refresh token", type: .info) + viewModel.logCall("getRefreshToken()", result: "nil (not signed in)") } } func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + let headers = await viewModel.clientApp.getAuthHeaders() - viewModel.log("Auth headers: \(headers.keys.joined(separator: ", "))", type: .success) - } - - func getPartialUser() async { - let user = await viewModel.clientApp.getPartialUser() - if let user = user { - viewModel.log("Partial user: \(user.primaryEmail ?? "nil")", type: .success) - } else { - viewModel.log("No partial user", type: .info) - } - } - - func listChannels() async { - do { - guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) - return - } - viewModel.log("Listing contact channels...") - let channelsList = try await user.listContactChannels() - var results: [(id: String, value: String, isPrimary: Bool)] = [] - for channel in channelsList { - let value = await channel.value - let isPrimary = await channel.isPrimary - results.append((id: channel.id, value: value, isPrimary: isPrimary)) - } - channels = results - viewModel.log("Found \(channels.count) channels", type: .success) - } catch { - viewModel.log("List channels failed: \(error)", type: .error) + var result = "Headers:\n" + for (key, value) in headers { + result += " \(key): \(value)\n" } + viewModel.logCall("getAuthHeaders()", result: result) } } @@ -555,196 +1036,488 @@ struct TeamsView: View { @State private var teamName = "" @State private var teams: [(id: String, name: String)] = [] @State private var selectedTeamId = "" - @State private var teamMembers: [String] = [] var body: some View { - List { + Form { Section("Create Team") { TextField("Team Name", text: $teamName) Button("Generate Random Name") { teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") } - Button("Create Team") { + Button("user.createTeam(displayName: teamName)") { Task { await createTeam() } } .disabled(teamName.isEmpty) } - Section("My Teams") { - Button("Refresh Teams") { + Section("List Teams") { + Button("user.listTeams()") { Task { await listTeams() } } ForEach(teams, id: \.id) { team in - Button { - selectedTeamId = team.id - Task { await listTeamMembers() } - } label: { - HStack { - Text(team.name) - Spacer() - if team.id == selectedTeamId { - Image(systemName: "checkmark") - } + HStack { + Text(team.name) + Spacer() + Text(team.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + selectedTeamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected team: \(team.id)") } + .buttonStyle(.borderless) } } } - if !selectedTeamId.isEmpty { - Section("Team Members (\(selectedTeamId.prefix(8))...)") { - Button("Refresh Members") { - Task { await listTeamMembers() } - } - - ForEach(teamMembers, id: \.self) { userId in - Text(userId) - .font(.caption) - } + Section("Team Operations") { + TextField("Team ID", text: $selectedTeamId) + .textInputAutocapitalization(.never) + + Button("user.getTeam(id: teamId)") { + Task { await getTeam() } } + .disabled(selectedTeamId.isEmpty) - Section("Team Actions") { - Button("Update Team Name") { - Task { await updateTeamName() } - } - .disabled(teamName.isEmpty) + Button("team.listUsers()") { + Task { await listTeamMembers() } } + .disabled(selectedTeamId.isEmpty) } } .navigationTitle("Teams") - .onAppear { - Task { await listTeams() } - } } func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + do { guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) + viewModel.logCall("createTeam()", result: "Error: No user signed in") return } - viewModel.log("Creating team: \(teamName)") let team = try await user.createTeam(displayName: teamName) - viewModel.log("Team created: \(team.id)", type: .success) + let dict = await serializeTeam(team) + viewModel.logCall( + "user.createTeam(displayName:)", + params: params, + result: formatObject("Team", dict) + ) await listTeams() } catch { - viewModel.log("Create team failed: \(error)", type: .error) + viewModel.logCall("user.createTeam(displayName:)", params: params, error: error) } } func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + do { guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) + viewModel.logCall("listTeams()", result: "Error: No user signed in") return } - viewModel.log("Listing teams...") let teamsList = try await user.listTeams() var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] for team in teamsList { - let name = await team.displayName - results.append((id: team.id, name: name)) + let dict = await serializeTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) } teams = results - viewModel.log("Found \(teams.count) teams", type: .success) + viewModel.logCall("user.listTeams()", result: formatObjectArray("Team", dicts)) + } catch { + viewModel.logCall("user.listTeams()", error: error) + } + } + + func getTeam() async { + let params = "id: \"\(selectedTeamId)\"" + viewModel.logInfo("getTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("getTeam()", result: "Error: No user signed in") + return + } + let team = try await user.getTeam(id: selectedTeamId) + if let team = team { + let dict = await serializeTeam(team) + viewModel.logCall( + "user.getTeam(id:)", + params: params, + result: formatObject("Team", dict) + ) + } else { + viewModel.logCall("user.getTeam(id:)", params: params, result: "nil (team not found or not a member)") + } } catch { - viewModel.log("List teams failed: \(error)", type: .error) + viewModel.logCall("user.getTeam(id:)", params: params, error: error) } } func listTeamMembers() async { + let params = "teamId: \"\(selectedTeamId)\"" + viewModel.logInfo("team.listUsers()", message: "Calling...", details: params) + do { guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) + viewModel.logCall("team.listUsers()", result: "Error: No user signed in") return } guard let team = try await user.getTeam(id: selectedTeamId) else { - viewModel.log("Team not found", type: .error) + viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found") return } - viewModel.log("Listing team members...") let members = try await team.listUsers() - teamMembers = members.map { $0.id } - viewModel.log("Found \(members.count) members", type: .success) + let dicts = members.map { serializeTeamUser($0) } + viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts)) } catch { - viewModel.log("List members failed: \(error)", type: .error) + viewModel.logCall("team.listUsers()", params: params, error: error) } } +} + +// MARK: - Contact Channels View + +struct ContactChannelsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var channels: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] - func updateTeamName() async { + var body: some View { + Form { + Section("Contact Channels") { + Button("user.listContactChannels()") { + Task { await listChannels() } + } + + ForEach(channels, id: \.id) { channel in + HStack { + Text(channel.value) + Spacer() + if channel.isPrimary { + Text("Primary") + .font(.caption) + .foregroundStyle(.blue) + } + if channel.isVerified { + Text("Verified") + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + } + .navigationTitle("Contact Channels") + } + + func listChannels() async { + viewModel.logInfo("listContactChannels()", message: "Calling...") + do { guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) + viewModel.logCall("listContactChannels()", result: "Error: No user signed in") return } - guard let team = try await user.getTeam(id: selectedTeamId) else { - viewModel.log("Team not found", type: .error) - return + let channelsList = try await user.listContactChannels() + var results: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + var dicts: [[String: Any]] = [] + for channel in channelsList { + let dict = await serializeContactChannel(channel) + dicts.append(dict) + results.append(( + id: channel.id, + value: dict["value"] as? String ?? "", + isPrimary: dict["isPrimary"] as? Bool ?? false, + isVerified: dict["isVerified"] as? Bool ?? false + )) + } + channels = results + viewModel.logCall("user.listContactChannels()", result: formatObjectArray("ContactChannel", dicts)) + } catch { + viewModel.logCall("user.listContactChannels()", error: error) + } + } +} + +// MARK: - OAuth View + +struct OAuthView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var provider = "google" + @State private var isSigningIn = false + private let presentationProvider = iOSPresentationContextProvider() + + var body: some View { + Form { + Section("Sign In with OAuth") { + TextField("Provider", text: $provider) + .textInputAutocapitalization(.never) + + HStack { + Button("google") { provider = "google" } + Button("github") { provider = "github" } + Button("microsoft") { provider = "microsoft" } + } + .buttonStyle(.bordered) + + Button { + Task { await signInWithOAuth() } + } label: { + HStack { + if isSigningIn { + ProgressView() + .scaleEffect(0.8) + } + Text("signInWithOAuth(provider: \"\(provider)\")") + } + } + .disabled(isSigningIn) + } + + Section("OAuth URL Generation (Manual)") { + Button("getOAuthUrl(provider: \"\(provider)\")") { + Task { await getOAuthUrl() } + } + + Text("Returns URL, state, and codeVerifier for manual OAuth handling") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .navigationTitle("OAuth") + } + + func signInWithOAuth() async { + let params = "provider: \"\(provider)\"" + viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params) + isSigningIn = true + + do { + try await viewModel.clientApp.signInWithOAuth( + provider: provider, + presentationContextProvider: presentationProvider + ) + viewModel.logCall( + "signInWithOAuth(provider:)", + params: params, + result: "Success! User signed in via OAuth." + ) + // Fetch user to show details + if let user = try await viewModel.clientApp.getUser() { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "getUser() after OAuth", + result: formatObject("CurrentUser", dict) + ) + } + } catch { + viewModel.logCall("signInWithOAuth(provider:)", params: params, error: error) + } + + isSigningIn = false + } + + func getOAuthUrl() async { + let params = "provider: \"\(provider)\"" + viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) + + do { + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) + viewModel.logCall( + "getOAuthUrl(provider:)", + params: params, + result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n}" + ) + } catch { + viewModel.logCall("getOAuthUrl(provider:)", params: params, error: error) + } + } +} + +// MARK: - Tokens View + +struct TokensView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + Form { + Section("Token Operations") { + Button("getAccessToken()") { + Task { await getAccessToken() } + } + + Button("getRefreshToken()") { + Task { await getRefreshToken() } + } + + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } + } + } + + Section("Token Store Types") { + Button("Test Memory Store") { + Task { await testMemoryStore() } + } + + Button("Test Explicit Store") { + Task { await testExplicitStore() } + } + } + } + .navigationTitle("Tokens") + } + + func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token:\n Parts: \(parts.count)\n Length: \(token.count) chars\n Token: \(token)" + ) + } else { + viewModel.logCall("getAccessToken()", result: "nil") + } + } + + func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token:\n Length: \(token.count) chars\n Token: \(token)" + ) + } else { + viewModel.logCall("getRefreshToken()", result: "nil") + } + } + + func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + + let headers = await viewModel.clientApp.getAuthHeaders() + var result = "Headers {\n" + for (key, value) in headers { + result += " \"\(key)\": \"\(value)\"\n" + } + result += "}" + viewModel.logCall("getAuthHeaders()", result: result) + } + + func testMemoryStore() async { + viewModel.logInfo("StackClientApp(tokenStore: .memory)", message: "Creating app with memory store...") + + let app = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .memory, + noAutomaticPrefetch: true + ) + let token = await app.getAccessToken() + viewModel.logCall( + "StackClientApp(tokenStore: .memory)", + result: "Created app with memory store\ngetAccessToken() = \(token == nil ? "nil" : "present")" + ) + } + + func testExplicitStore() async { + viewModel.logInfo("Testing explicit token store...", message: "Getting tokens from current app...") + + let accessToken = await viewModel.clientApp.getAccessToken() + let refreshToken = await viewModel.clientApp.getRefreshToken() + + guard let at = accessToken, let rt = refreshToken else { + viewModel.logCall("testExplicitStore()", result: "Error: No tokens available. Sign in first.") + return + } + + let app = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .explicit(accessToken: at, refreshToken: rt), + noAutomaticPrefetch: true + ) + + do { + let user = try await app.getUser() + if let user = user { + let email = await user.primaryEmail + viewModel.logCall( + "StackClientApp(tokenStore: .explicit(...))", + result: "Success! Created app with explicit tokens\ngetUser() returned: \(email ?? "no email")" + ) + } else { + viewModel.logCall( + "StackClientApp(tokenStore: .explicit(...))", + result: "App created but getUser() returned nil" + ) } - viewModel.log("Updating team name: \(teamName)") - try await team.update(displayName: teamName) - viewModel.log("Team updated!", type: .success) - await listTeams() } catch { - viewModel.log("Update team failed: \(error)", type: .error) + viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error) } } } -// MARK: - Server View +// MARK: - Server Users View -struct ServerView: View { +struct ServerUsersView: View { @Bindable var viewModel: SDKTestViewModel @State private var email = "" @State private var displayName = "" @State private var userId = "" - @State private var teamName = "" - @State private var teamId = "" @State private var users: [(id: String, email: String?)] = [] - @State private var teams: [(id: String, name: String)] = [] var body: some View { - List { + Form { Section("Create User") { TextField("Email", text: $email) .textInputAutocapitalization(.never) - .autocorrectionDisabled() .keyboardType(.emailAddress) - TextField("Display Name", text: $displayName) + TextField("Display Name (optional)", text: $displayName) Button("Generate Random Email") { - email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") } - Button("Create User") { + Button("serverApp.createUser(email: email)") { Task { await createUser() } } .disabled(email.isEmpty) - - Button("Create User (All Options)") { - Task { await createUserWithOptions() } - } - .disabled(email.isEmpty) } - Section("Users") { - Button("List Users") { + Section("List Users") { + Button("serverApp.listUsers(limit: 5)") { Task { await listUsers() } } ForEach(users, id: \.id) { user in - Button { - userId = user.id - } label: { - HStack { - Text(user.email ?? "no email") - Spacer() - if user.id == userId { - Image(systemName: "checkmark") - } + HStack { + Text(user.email ?? "no email") + Spacer() + Text(user.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + userId = user.id + viewModel.logInfo("selectUser()", message: "Selected: \(user.id)") } + .buttonStyle(.borderless) } } } @@ -752,261 +1525,343 @@ struct ServerView: View { Section("User Operations") { TextField("User ID", text: $userId) .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button("Get User") { + Button("serverApp.getUser(id: userId)") { Task { await getUser() } } .disabled(userId.isEmpty) - Button("Delete User") { + Button("user.delete()") { Task { await deleteUser() } } .disabled(userId.isEmpty) - - Button("Create Session (Impersonate)") { - Task { await createSession() } - } - .disabled(userId.isEmpty) - } - - Section("Create Team") { - TextField("Team Name", text: $teamName) - - Button("Generate Random Name") { - teamName = "Team \(UUID().uuidString.prefix(8))" - } - - Button("Create Team") { - Task { await createTeam() } - } - .disabled(teamName.isEmpty) - } - - Section("Teams") { - Button("List Teams") { - Task { await listTeams() } - } - - ForEach(teams, id: \.id) { team in - Button { - teamId = team.id - } label: { - HStack { - Text(team.name) - Spacer() - if team.id == teamId { - Image(systemName: "checkmark") - } - } - } - } - } - - Section("Team Operations") { - TextField("Team ID", text: $teamId) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Button("Add User to Team") { - Task { await addUserToTeam() } - } - .disabled(teamId.isEmpty || userId.isEmpty) - - Button("Remove User from Team") { - Task { await removeUserFromTeam() } - } - .disabled(teamId.isEmpty || userId.isEmpty) - - Button("List Team Users") { - Task { await listTeamUsers() } - } - .disabled(teamId.isEmpty) - - Button("Delete Team") { - Task { await deleteTeam() } - } - .disabled(teamId.isEmpty) } } - .navigationTitle("Server") + .navigationTitle("Server Users") } func createUser() async { + let params = "email: \"\(email)\"" + viewModel.logInfo("createUser()", message: "Calling...", details: params) + do { - viewModel.log("Creating user: \(email)") let user = try await viewModel.serverApp.createUser(email: email) - viewModel.log("User created: \(user.id)", type: .success) - userId = user.id - await listUsers() - } catch { - viewModel.log("Create user failed: \(error)", type: .error) - } - } - - func createUserWithOptions() async { - do { - viewModel.log("Creating user with options: \(email)") - let user = try await viewModel.serverApp.createUser( - email: email, - password: "TestPassword123!", - displayName: displayName.isEmpty ? nil : displayName, - primaryEmailVerified: true, - clientMetadata: ["source": "iOS-example"], - serverMetadata: ["created_via": "example-app"] + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.createUser(email:)", + params: params, + result: formatObject("ServerUser", dict) ) - viewModel.log("User created: \(user.id)", type: .success) userId = user.id await listUsers() } catch { - viewModel.log("Create user failed: \(error)", type: .error) + viewModel.logCall("serverApp.createUser(email:)", params: params, error: error) } } func listUsers() async { + let params = "limit: 5" + viewModel.logInfo("listUsers()", message: "Calling...", details: params) + do { - viewModel.log("Listing users...") let result = try await viewModel.serverApp.listUsers(limit: 5) var usersList: [(id: String, email: String?)] = [] + var dicts: [[String: Any]] = [] for user in result.items { - let email = await user.primaryEmail - usersList.append((id: user.id, email: email)) + let dict = await serializeServerUser(user) + dicts.append(dict) + usersList.append((id: user.id, email: dict["primaryEmail"] as? String)) } users = usersList - viewModel.log("Found \(users.count) users", type: .success) + viewModel.logCall("serverApp.listUsers(limit:)", params: params, result: formatObjectArray("ServerUser", dicts)) } catch { - viewModel.log("List users failed: \(error)", type: .error) + viewModel.logCall("serverApp.listUsers(limit:)", params: params, error: error) } } func getUser() async { + let params = "id: \"\(userId)\"" + viewModel.logInfo("getUser()", message: "Calling...", details: params) + do { - viewModel.log("Getting user: \(userId)") let user = try await viewModel.serverApp.getUser(id: userId) if let user = user { - let email = await user.primaryEmail - viewModel.log("User: \(email ?? "nil")", type: .success) + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.getUser(id:)", + params: params, + result: formatObject("ServerUser", dict) + ) } else { - viewModel.log("User not found", type: .info) + viewModel.logCall("serverApp.getUser(id:)", params: params, result: "nil (user not found)") } } catch { - viewModel.log("Get user failed: \(error)", type: .error) + viewModel.logCall("serverApp.getUser(id:)", params: params, error: error) } } func deleteUser() async { + let params = "userId: \"\(userId)\"" + viewModel.logInfo("user.delete()", message: "Calling...", details: params) + do { - viewModel.log("Deleting user: \(userId)") guard let user = try await viewModel.serverApp.getUser(id: userId) else { - viewModel.log("User not found", type: .error) + viewModel.logCall("user.delete()", params: params, result: "Error: User not found") return } try await user.delete() - viewModel.log("User deleted!", type: .success) + viewModel.logCall("user.delete()", params: params, result: "Success! User deleted.") userId = "" await listUsers() } catch { - viewModel.log("Delete user failed: \(error)", type: .error) + viewModel.logCall("user.delete()", params: params, error: error) } } +} + +// MARK: - Server Teams View + +struct ServerTeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teamId = "" + @State private var userIdToAdd = "" + @State private var teams: [(id: String, name: String)] = [] - func createSession() async { - do { - viewModel.log("Creating session for: \(userId)") - let tokens = try await viewModel.serverApp.createSession(userId: userId) - viewModel.log("Session created!", type: .success) - viewModel.log("Access token: \(tokens.accessToken.prefix(30))...", type: .info) - } catch { - viewModel.log("Create session failed: \(error)", type: .error) + var body: some View { + Form { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") + } + + Button("serverApp.createTeam(displayName: teamName)") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("List Teams") { + Button("serverApp.listTeams()") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + HStack { + Text(team.name) + Spacer() + Text(team.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + teamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected: \(team.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("Team Membership") { + TextField("Team ID", text: $teamId) + .textInputAutocapitalization(.never) + TextField("User ID", text: $userIdToAdd) + .textInputAutocapitalization(.never) + + Button("team.addUser(id: userId)") { + Task { await addUserToTeam() } + } + .disabled(teamId.isEmpty || userIdToAdd.isEmpty) + + Button("team.removeUser(id: userId)") { + Task { await removeUserFromTeam() } + } + .disabled(teamId.isEmpty || userIdToAdd.isEmpty) + } } + .navigationTitle("Server Teams") } func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + do { - viewModel.log("Creating team: \(teamName)") let team = try await viewModel.serverApp.createTeam(displayName: teamName) - viewModel.log("Team created: \(team.id)", type: .success) + let dict = await serializeServerTeam(team) + viewModel.logCall( + "serverApp.createTeam(displayName:)", + params: params, + result: formatObject("ServerTeam", dict) + ) teamId = team.id await listTeams() } catch { - viewModel.log("Create team failed: \(error)", type: .error) + viewModel.logCall("serverApp.createTeam(displayName:)", params: params, error: error) } } func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + do { - viewModel.log("Listing teams...") let teamsList = try await viewModel.serverApp.listTeams() var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] for team in teamsList { - let name = await team.displayName - results.append((id: team.id, name: name)) + let dict = await serializeServerTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) } teams = results - viewModel.log("Found \(teams.count) teams", type: .success) + viewModel.logCall("serverApp.listTeams()", result: formatObjectArray("ServerTeam", dicts)) } catch { - viewModel.log("List teams failed: \(error)", type: .error) + viewModel.logCall("serverApp.listTeams()", error: error) } } func addUserToTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.addUser()", message: "Calling...", details: params) + do { - viewModel.log("Adding user to team...") guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) + viewModel.logCall("team.addUser()", params: params, result: "Error: Team not found") return } - try await team.addUser(id: userId) - viewModel.log("User added to team!", type: .success) + try await team.addUser(id: userIdToAdd) + let dict = await serializeServerTeam(team) + viewModel.logCall("team.addUser(id:)", params: params, result: "Success! User added to team.\n\n" + formatObject("ServerTeam", dict)) } catch { - viewModel.log("Add user failed: \(error)", type: .error) + viewModel.logCall("team.addUser(id:)", params: params, error: error) } } func removeUserFromTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.removeUser()", message: "Calling...", details: params) + do { - viewModel.log("Removing user from team...") guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) + viewModel.logCall("team.removeUser()", params: params, result: "Error: Team not found") return } - try await team.removeUser(id: userId) - viewModel.log("User removed from team!", type: .success) + try await team.removeUser(id: userIdToAdd) + let dict = await serializeServerTeam(team) + viewModel.logCall("team.removeUser(id:)", params: params, result: "Success! User removed from team.\n\n" + formatObject("ServerTeam", dict)) } catch { - viewModel.log("Remove user failed: \(error)", type: .error) + viewModel.logCall("team.removeUser(id:)", params: params, error: error) } } +} + +// MARK: - Sessions View + +struct SessionsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var userId = "" + @State private var accessToken = "" + @State private var refreshToken = "" - func listTeamUsers() async { - do { - viewModel.log("Listing team users...") - guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) - return + var body: some View { + Form { + Section("Create Session (Impersonation)") { + TextField("User ID", text: $userId) + .textInputAutocapitalization(.never) + + Button("serverApp.createSession(userId: userId)") { + Task { await createSession() } + } + .disabled(userId.isEmpty) } - let users = try await team.listUsers() - viewModel.log("Found \(users.count) users", type: .success) - for user in users { - viewModel.log(" - \(user.id)", type: .info) + + if !accessToken.isEmpty { + Section("Session Tokens") { + VStack(alignment: .leading) { + Text("Access Token:") + .font(.headline) + Text(accessToken.prefix(100) + "...") + .font(.system(.caption, design: .monospaced)) + } + + VStack(alignment: .leading) { + Text("Refresh Token:") + .font(.headline) + Text(refreshToken.prefix(50) + "...") + .font(.system(.caption, design: .monospaced)) + } + + Button("Copy Access Token") { + UIPasteboard.general.string = accessToken + } + + Button("Copy Refresh Token") { + UIPasteboard.general.string = refreshToken + } + } + + Section("Use Session") { + Button("Create Client with Session Tokens") { + Task { await useSessionTokens() } + } + } } + } + .navigationTitle("Sessions") + } + + func createSession() async { + let params = "userId: \"\(userId)\"" + viewModel.logInfo("createSession()", message: "Calling...", details: params) + + do { + let tokens = try await viewModel.serverApp.createSession(userId: userId) + accessToken = tokens.accessToken + refreshToken = tokens.refreshToken + viewModel.logCall( + "serverApp.createSession(userId:)", + params: params, + result: """ + SessionTokens { + accessToken: "\(tokens.accessToken.prefix(50))..." + refreshToken: "\(tokens.refreshToken.prefix(30))..." + } + """ + ) } catch { - viewModel.log("List team users failed: \(error)", type: .error) + viewModel.logCall("serverApp.createSession(userId:)", params: params, error: error) } } - func deleteTeam() async { + func useSessionTokens() async { + viewModel.logInfo("StackClientApp(tokenStore: .explicit(...))", message: "Creating client with session tokens...") + do { - viewModel.log("Deleting team: \(teamId)") - guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) - return + let client = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .explicit(accessToken: accessToken, refreshToken: refreshToken), + noAutomaticPrefetch: true + ) + let user = try await client.getUser() + if let user = user { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "clientWithTokens.getUser()", + result: "Success! Authenticated user:\n\n" + formatObject("CurrentUser", dict) + ) + } else { + viewModel.logCall( + "clientWithTokens.getUser()", + result: "nil (tokens may be invalid)" + ) } - try await team.delete() - viewModel.log("Team deleted!", type: .success) - teamId = "" - await listTeams() } catch { - viewModel.log("Delete team failed: \(error)", type: .error) + viewModel.logCall("clientWithTokens.getUser()", error: error) } } } diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index e0f6f40512..d199e059d9 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -7,6 +7,6 @@ "test": "swift test", "build": "swift build", "start:mac-example": "cd Examples/StackAuthMacOS && swift run", - "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/Package.swift'" + "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/StackAuthiOS.xcodeproj'" } } From 999956b46e998213a6ed30aaab5aaedbdbee7ea6 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 14:42:05 -0800 Subject: [PATCH 10/17] fixes --- sdks/implementations/swift/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index d199e059d9..a611648367 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -5,7 +5,7 @@ "description": "Stack Auth Swift SDK", "scripts": { "test": "swift test", - "build": "swift build", + "clean": "swift package clean", "start:mac-example": "cd Examples/StackAuthMacOS && swift run", "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/StackAuthiOS.xcodeproj'" } From 6597d7e841e712657c4add1894653aa0e3e60704 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 15:08:25 -0800 Subject: [PATCH 11/17] many small fixes --- pnpm-lock.yaml | 2 ++ sdks/implementations/swift/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f55d50850..4dc02369f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2198,6 +2198,8 @@ importers: specifier: ^8.0.2 version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.0) + sdks/implementations/swift: {} + sdks/spec: {} packages: diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index a611648367..e2c37f80fd 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/swift-sdk", - "version": "0.0.0", + "version": "0.0.3", "private": true, "description": "Stack Auth Swift SDK", "scripts": { From 1034d2e508048dfb0b730bbcc217d99ef7e98f75 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 15:53:15 -0800 Subject: [PATCH 12/17] github actions script --- .github/workflows/swift-sdk-publish.yaml | 100 ++++++++++++++++++ .../swift/Examples/StackAuthiOS/README.md | 8 +- sdks/implementations/swift/README.md | 5 +- .../swift/Sources/StackAuth/APIClient.swift | 20 +++- .../StackAuth/Models/CurrentUser.swift | 2 +- .../Sources/StackAuth/Models/Permission.swift | 8 ++ .../Sources/StackAuth/Models/Session.swift | 9 +- .../swift/Sources/StackAuth/Models/Team.swift | 2 +- .../Tests/StackAuthTests/TestConfig.swift | 2 +- sdks/spec/src/_utilities.spec.md | 2 +- sdks/spec/src/types/common/api-keys.spec.md | 2 +- sdks/spec/src/types/teams/team.spec.md | 2 +- .../spec/src/types/users/current-user.spec.md | 6 +- 13 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/swift-sdk-publish.yaml diff --git a/.github/workflows/swift-sdk-publish.yaml b/.github/workflows/swift-sdk-publish.yaml new file mode 100644 index 0000000000..4532a429ff --- /dev/null +++ b/.github/workflows/swift-sdk-publish.yaml @@ -0,0 +1,100 @@ +name: Publish Swift SDK to prerelease repo + +on: + push: + branches: + - main + paths: + - 'sdks/implementations/swift/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false # Don't cancel publishing in progress + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + with: + path: source + + - name: Read version from package.json + id: version + run: | + VERSION=$(jq -r '.version' source/sdks/implementations/swift/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Swift SDK version: $VERSION" + + - name: Check if tag already exists in target repo + id: check-tag + run: | + TAG="v${{ steps.version.outputs.version }}" + echo "Checking if tag $TAG exists in stack-auth/swift-sdk-prerelease..." + + # Use the GitHub API to check if the tag exists + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/stack-auth/swift-sdk-prerelease/git/refs/tags/$TAG") + + if [ "$HTTP_STATUS" = "200" ]; then + echo "Tag $TAG already exists, skipping publish" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "Tag $TAG does not exist, will publish" + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Clone target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + git clone https://x-access-token:${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}@github.com/stack-auth/swift-sdk-prerelease.git target + + - name: Copy Swift SDK to target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + # Remove all files except .git from target + cd target + find . -maxdepth 1 -not -name '.git' -not -name '.' -exec rm -rf {} + + cd .. + + # Copy everything from Swift SDK + cp -r source/sdks/implementations/swift/* target/ + cp source/sdks/implementations/swift/.gitignore target/ 2>/dev/null || true + + # Remove package.json (it's only for turborepo integration, not part of the Swift package) + rm -f target/package.json + + - name: Commit and push to target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + cd target + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + git add -A + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Release v${{ steps.version.outputs.version }}" + fi + + # Create and push tag + TAG="v${{ steps.version.outputs.version }}" + git tag "$TAG" + git push origin main --tags + + echo "Successfully published Swift SDK v${{ steps.version.outputs.version }}" + + - name: Summary + run: | + if [ "${{ steps.check-tag.outputs.exists }}" = "true" ]; then + echo "::notice::Skipped publishing - tag v${{ steps.version.outputs.version }} already exists" + else + echo "::notice::Published Swift SDK v${{ steps.version.outputs.version }} to stack-auth/swift-sdk-prerelease" + fi diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md index 6b425829f0..b05669a11c 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/README.md +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -17,18 +17,18 @@ An interactive iOS application for testing all Stack Auth Swift SDK functions. open StackAuthiOS.xcodeproj ``` -2. Select an iOS Simulator (e.g., "iPhone 17 Pro") as the destination +2. Select an iOS Simulator (e.g., "iPhone 15 Pro" or any available device) as the destination 3. Press ⌘R to build and run ### Option 2: Command Line ```bash -# Build -xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +# Build (replace device name with an available simulator on your system) +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build # Build and run (opens simulator) -xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' run +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' run ``` ## Features diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index c6c3fb9d3b..623fa8d2a4 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -13,7 +13,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/stack-auth/stack-swift", from: "1.0.0") + .package(url: "https://github.com/stack-auth/swift-sdk-prerelease", from: ) ] ``` @@ -36,7 +36,7 @@ if let user = try await stack.getUser() { } // Sign out -try await user.signOut() +try await stack.signOut() ``` ## Design Decisions @@ -126,7 +126,6 @@ Task { | OAuth | Browser redirect | ASWebAuthenticationSession | | Redirect methods | Available | Not available (browser-only) | | React hooks | `useUser()` etc. | Not applicable | -| Error handling | Result types | `throws` | ### Not Available in Swift diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index e7aa3a3895..fe5d3b203c 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -35,7 +35,9 @@ actor APIClient { authenticated: Bool = false, serverOnly: Bool = false ) async throws -> (Data, HTTPURLResponse) { - let url = URL(string: "\(baseUrl)/api/v1\(path)")! + guard let url = URL(string: "\(baseUrl)/api/v1\(path)") else { + throw StackAuthError(code: "INVALID_URL", message: "Failed to construct request URL from base: \(baseUrl) and path: \(path)") + } var request = URLRequest(url: url) request.httpMethod = method request.cachePolicy = .reloadIgnoringLocalCacheData @@ -119,13 +121,23 @@ actor APIClient { } } - // Handle rate limiting - if actualStatus == 429 { + // Handle rate limiting (max 5 retries) + if actualStatus == 429 && attempt < 5 { if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"), let seconds = Double(retryAfter) { + // Use Retry-After header if provided try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } else { + // No Retry-After header: use exponential backoff (1s, 2s, 4s, 8s, 16s) + let delayMs = 1000.0 * pow(2.0, Double(attempt)) + try await Task.sleep(nanoseconds: UInt64(delayMs * 1_000_000)) } + return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } + + // Rate limit exhausted after max retries + if actualStatus == 429 { + throw StackAuthError(code: "RATE_LIMITED", message: "Too many requests, please try again later") } // Check for known error diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift index 92d0915f0a..9d1d01dfb7 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -336,7 +336,7 @@ public actor CurrentUser { ) async throws -> UserApiKeyFirstView { var body: [String: Any] = ["description": description] if let expiresAt = expiresAt { - body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) } if let scope = scope { body["scope"] = scope } if let teamId = teamId { body["team_id"] = teamId } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift index cdb2a8492b..b1fc78b702 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift @@ -3,9 +3,17 @@ import Foundation /// A permission granted to a user within a team or project public struct TeamPermission: Sendable { public let id: String + + public init(id: String) { + self.id = id + } } /// A project-level permission public struct ProjectPermission: Sendable { public let id: String + + public init(id: String) { + self.id = id + } } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift index 7e5fc316a2..6af001c816 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift @@ -14,13 +14,14 @@ public struct ActiveSession: Sendable { self.id = json["id"] as? String ?? "" self.userId = json["user_id"] as? String ?? "" - let createdMillis = json["created_at"] as? Int64 ?? json["created_at_millis"] as? Int64 ?? 0 - self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + // JSONSerialization returns NSNumber for numeric values, use doubleValue for reliable parsing + let createdMillis = (json["created_at"] as? NSNumber)?.doubleValue ?? 0 + self.createdAt = Date(timeIntervalSince1970: createdMillis / 1000.0) self.isImpersonation = json["is_impersonation"] as? Bool ?? false - if let lastUsedMillis = json["last_used_at"] as? Int64 ?? json["last_used_at_millis"] as? Int64 { - self.lastUsedAt = Date(timeIntervalSince1970: Double(lastUsedMillis) / 1000.0) + if let lastUsedRaw = json["last_used_at"] as? NSNumber { + self.lastUsedAt = Date(timeIntervalSince1970: lastUsedRaw.doubleValue / 1000.0) } else { self.lastUsedAt = nil } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift index 598bde101c..d2b0ac2f93 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift @@ -133,7 +133,7 @@ public actor Team { ) async throws -> TeamApiKeyFirstView { var body: [String: Any] = ["description": description] if let expiresAt = expiresAt { - body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) } if let scope = scope { body["scope"] = scope } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index 168fa14c28..bc073ea92b 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -25,7 +25,7 @@ struct TestConfig { do { let (_, response) = try await URLSession.shared.data(from: url) if let httpResponse = response as? HTTPURLResponse { - return httpResponse.statusCode < 500 + return (200..<300).contains(httpResponse.statusCode) } return false } catch { diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index da5aaf860d..8b22377e13 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -215,7 +215,7 @@ Use getAuthHeaders() to generate this header value. Several sign-in methods may return MultiFactorAuthenticationRequired error when MFA is enabled. Error format: - code: "multi_factor_authentication_required" + code: "MULTI_FACTOR_AUTHENTICATION_REQUIRED" message: "Multi-factor authentication is required." details: { attempt_code: string } diff --git a/sdks/spec/src/types/common/api-keys.spec.md b/sdks/spec/src/types/common/api-keys.spec.md index d0cc8f6195..c32ccf1b97 100644 --- a/sdks/spec/src/types/common/api-keys.spec.md +++ b/sdks/spec/src/types/common/api-keys.spec.md @@ -38,7 +38,7 @@ Does not error. options.description: string? options.expiresAt: Date | null? -PATCH /api/v1/api-keys/{id} { description, expires_at } [authenticated] +PATCH /api/v1/api-keys/{id} { description, expires_at_millis } [authenticated] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 395f32bedc..6373bf8381 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -102,7 +102,7 @@ options.scope: string? Returns: TeamApiKeyFirstView -POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] +POST /api/v1/teams/{teamId}/api-keys { description, expires_at_millis, scope } [authenticated] See types/common/api-keys.spec.md for TeamApiKeyFirstView. The apiKey property is only returned once at creation time. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index 8c65e00d10..c6ec04bfb3 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -5,8 +5,8 @@ The authenticated user with methods to modify their own data. Extends: User (base-user.spec.md) Also includes: - - Auth methods (signOut, getAccessToken, etc.) - - Customer methods (payments/customer.spec.md) +- Auth methods (signOut, getAccessToken, etc.) +- Customer methods (payments/customer.spec.md) ## Additional Properties @@ -404,7 +404,7 @@ options.teamId: string? - for team-scoped keys Returns: UserApiKeyFirstView -POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] +POST /api/v1/users/me/api-keys { description, expires_at_millis, scope, team_id } [authenticated] See types/common/api-keys.spec.md for UserApiKeyFirstView. The apiKey property is only returned once at creation time. From 33e329184a6a0990aad22dfefacd62bf56f7ea8b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 16:42:50 -0800 Subject: [PATCH 13/17] Swift test in E2E tests --- .../tests/general/sdk-implementations.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/e2e/tests/general/sdk-implementations.test.ts diff --git a/apps/e2e/tests/general/sdk-implementations.test.ts b/apps/e2e/tests/general/sdk-implementations.test.ts new file mode 100644 index 0000000000..4225511d66 --- /dev/null +++ b/apps/e2e/tests/general/sdk-implementations.test.ts @@ -0,0 +1,58 @@ +import { exec } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { describe } from "vitest"; +import { it } from "../helpers"; + +// Find all SDK implementations that have a package.json +function findSdkImplementations(): string[] { + const implementationsDir = path.resolve(__dirname, "../../../../sdks/implementations"); + + if (!fs.existsSync(implementationsDir)) { + return []; + } + + const entries = fs.readdirSync(implementationsDir, { withFileTypes: true }); + const sdkDirs: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const packageJsonPath = path.join(implementationsDir, entry.name, "package.json"); + if (fs.existsSync(packageJsonPath)) { + sdkDirs.push(entry.name); + } + } + } + + return sdkDirs; +} + +const sdkImplementations = findSdkImplementations(); + +describe("SDK implementation tests", () => { + for (const sdk of sdkImplementations) { + describe(`${sdk} SDK`, () => { + it("runs tests successfully", async ({ expect }) => { + const sdkDir = path.resolve(__dirname, `../../../../sdks/implementations/${sdk}`); + + const [error, stdout, stderr] = await new Promise<[Error | null, string, string]>((resolve) => { + exec("pnpm run test", { cwd: sdkDir }, (error, stdout, stderr) => { + resolve([error, stdout, stderr]); + }); + }); + + expect( + error, + `Expected ${sdk} SDK tests to pass!\n\n\n\nstdout: ${stdout}\n\n\n\nstderr: ${stderr}` + ).toBeNull(); + }, 300_000); // 5 minute timeout for SDK tests + }); + } + + // If no SDKs found, add a placeholder test so the describe block isn't empty + if (sdkImplementations.length === 0) { + it("has no SDK implementations to test", ({ expect }) => { + expect(true).toBe(true); + }); + } +}); From a64c59f75958a22237da3f298cb401bed820ddf3 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 20:19:48 -0800 Subject: [PATCH 14/17] Use Swift Crypto --- .../xcshareddata/swiftpm/Package.resolved | 23 +++++++++++++++++++ sdks/implementations/swift/Package.resolved | 23 +++++++++++++++++++ sdks/implementations/swift/Package.swift | 9 ++++++-- .../Sources/StackAuth/StackClientApp.swift | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 sdks/implementations/swift/Package.resolved diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..fc679a3014 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 2 +} diff --git a/sdks/implementations/swift/Package.resolved b/sdks/implementations/swift/Package.resolved new file mode 100644 index 0000000000..fc679a3014 --- /dev/null +++ b/sdks/implementations/swift/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 2 +} diff --git a/sdks/implementations/swift/Package.swift b/sdks/implementations/swift/Package.swift index 9ba21f29d0..42a9571e9d 100644 --- a/sdks/implementations/swift/Package.swift +++ b/sdks/implementations/swift/Package.swift @@ -16,11 +16,16 @@ let package = Package( targets: ["StackAuth"] ), ], - dependencies: [], + dependencies: [ + // Cross-platform crypto (provides CryptoKit API on Linux) + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + ], targets: [ .target( name: "StackAuth", - dependencies: [], + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + ], path: "Sources/StackAuth" ), .testTarget( diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 4c472e79a5..83947aef8d 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -1,5 +1,5 @@ import Foundation -import CryptoKit +import Crypto #if canImport(AuthenticationServices) import AuthenticationServices #endif From f183e072d1d273142a7e896f3bfb341cd24388c3 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 23:35:21 -0800 Subject: [PATCH 15/17] fix --- sdks/spec/src/_utilities.spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 8b22377e13..510b1a986d 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -163,7 +163,7 @@ Many functions also accept a tokenStore parameter to override storage for that c TokenStoreInit is a union type representing the different ways to provide token storage: -``` +```ts TokenStoreInit = | "cookie" // [JS-ONLY] Browser cookies | "memory" // In-memory storage From 7a2a324b2384bb37dfd2d18265a41569c68a598c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 20 Jan 2026 15:32:33 -0800 Subject: [PATCH 16/17] Fix build --- .../Sources/StackAuth/StackClientApp.swift | 41 +++++++++++++++++++ .../swift/Sources/StackAuth/TokenStore.swift | 9 +++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 83947aef8d..9f05a2c532 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -86,6 +86,7 @@ public actor StackClientApp { let client: APIClient private let baseUrl: String + #if canImport(Security) public init( projectId: String, publishableClientKey: String, @@ -126,6 +127,46 @@ public actor StackClientApp { } } } + #else + public init( + projectId: String, + publishableClientKey: String, + baseUrl: String = "https://api.stack-auth.com", + tokenStore: TokenStore = .memory, + urls: HandlerUrls = HandlerUrls(), + noAutomaticPrefetch: Bool = false + ) { + self.projectId = projectId + self.baseUrl = baseUrl + self.urls = urls + + let store: any TokenStoreProtocol + switch tokenStore { + case .memory: + store = MemoryTokenStore() + case .explicit(let accessToken, let refreshToken): + store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) + case .none: + store = NullTokenStore() + case .custom(let customStore): + store = customStore + } + + self.client = APIClient( + baseUrl: baseUrl, + projectId: projectId, + publishableClientKey: publishableClientKey, + tokenStore: store + ) + + // Prefetch project info + if !noAutomaticPrefetch { + Task { + _ = try? await self.getProject() + } + } + } + #endif // MARK: - OAuth diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index f3cbe5d3b6..ff36a6c159 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(Security) import Security +#endif /// Protocol for custom token storage implementations public protocol TokenStoreProtocol: Sendable { @@ -11,8 +13,11 @@ public protocol TokenStoreProtocol: Sendable { /// Token storage configuration public enum TokenStore: Sendable { + #if canImport(Security) /// Store tokens in Keychain (default, secure, persists across launches) + /// Only available on Apple platforms (iOS, macOS, etc.) case keychain + #endif /// Store tokens in memory (lost on app restart) case memory @@ -27,8 +32,9 @@ public enum TokenStore: Sendable { case custom(any TokenStoreProtocol) } -// MARK: - Keychain Token Store +// MARK: - Keychain Token Store (Apple platforms only) +#if canImport(Security) actor KeychainTokenStore: TokenStoreProtocol { private let projectId: String private let accessTokenKey: String @@ -126,6 +132,7 @@ actor KeychainTokenStore: TokenStoreProtocol { SecItemDelete(query as CFDictionary) } } +#endif // MARK: - Memory Token Store From 738ded9ebda5359516cbebfd0da7a18ed37dff33 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 20 Jan 2026 15:58:11 -0800 Subject: [PATCH 17/17] more fixes --- .../swift/Sources/StackAuth/APIClient.swift | 23 +++++++-- .../StackAuth/Models/CurrentUser.swift | 13 +++-- .../Sources/StackAuth/StackClientApp.swift | 26 ++++++---- .../swift/Sources/StackAuth/TokenStore.swift | 49 ++++++++++++++++--- .../Tests/StackAuthTests/TestConfig.swift | 3 ++ sdks/spec/src/_utilities.spec.md | 27 +++++++++- 6 files changed, 115 insertions(+), 26 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index fe5d3b203c..dadf1f30f9 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -1,4 +1,21 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Character set for form-urlencoded values. +/// Only unreserved characters (RFC 3986) are allowed; everything else must be percent-encoded. +/// This is stricter than urlQueryAllowed which incorrectly allows &, =, + etc. +private let formURLEncodedAllowedCharacters: CharacterSet = { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return allowed +}() + +/// Percent-encode a string for use in application/x-www-form-urlencoded data +func formURLEncode(_ string: String) -> String { + return string.addingPercentEncoding(withAllowedCharacters: formURLEncodedAllowedCharacters) ?? string +} /// Internal API client for making HTTP requests to Stack Auth actor APIClient { @@ -202,9 +219,9 @@ actor APIClient { let body = [ "grant_type=refresh_token", - "refresh_token=\(refreshToken.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? refreshToken)", - "client_id=\(projectId)", - "client_secret=\(publishableClientKey)" + "refresh_token=\(formURLEncode(refreshToken))", + "client_id=\(formURLEncode(projectId))", + "client_secret=\(formURLEncode(publishableClientKey))" ].joined(separator: "&") request.httpBody = body.data(using: .utf8) diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift index 9d1d01dfb7..d0a3a2e057 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -259,10 +259,15 @@ public actor CurrentUser { let accessToken = await client.getAccessToken() let refreshToken = await client.getRefreshToken() - let json: [String: Any?] = [ - "accessToken": accessToken, - "refreshToken": refreshToken - ] + // Build JSON object with only non-nil values + // JSONSerialization cannot serialize nil, so we must filter them out + var json: [String: Any] = [:] + if let accessToken = accessToken { + json["accessToken"] = accessToken + } + if let refreshToken = refreshToken { + json["refreshToken"] = refreshToken + } if let data = try? JSONSerialization.data(withJSONObject: json), let string = String(data: data, encoding: .utf8) { diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 9f05a2c532..80a18867da 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Crypto #if canImport(AuthenticationServices) import AuthenticationServices @@ -286,11 +289,11 @@ public actor StackClientApp { let publishableKey = await client.publishableClientKey let body = [ "grant_type=authorization_code", - "code=\(code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? code)", - "redirect_uri=\(urls.oauthCallback.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? urls.oauthCallback)", - "code_verifier=\(codeVerifier)", - "client_id=\(projectId)", - "client_secret=\(publishableKey)" + "code=\(formURLEncode(code))", + "redirect_uri=\(formURLEncode(urls.oauthCallback))", + "code_verifier=\(formURLEncode(codeVerifier))", + "client_id=\(formURLEncode(projectId))", + "client_secret=\(formURLEncode(publishableKey))" ].joined(separator: "&") request.httpBody = body.data(using: .utf8) @@ -668,10 +671,15 @@ public actor StackClientApp { let accessToken = await client.getAccessToken() let refreshToken = await client.getRefreshToken() - let json: [String: Any?] = [ - "accessToken": accessToken, - "refreshToken": refreshToken - ] + // Build JSON object with only non-nil values + // JSONSerialization cannot serialize nil, so we must filter them out + var json: [String: Any] = [:] + if let accessToken = accessToken { + json["accessToken"] = accessToken + } + if let refreshToken = refreshToken { + json["refreshToken"] = refreshToken + } if let data = try? JSONSerialization.data(withJSONObject: json), let string = String(data: data, encoding: .utf8) { diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index ff36a6c159..0e7266149e 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -161,9 +161,12 @@ actor MemoryTokenStore: TokenStoreProtocol { // MARK: - Explicit Token Store +/// Token store initialized with explicit tokens. +/// Starts with the provided tokens, but stores any refreshed tokens in memory +/// to avoid infinite refresh loops when access tokens expire. actor ExplicitTokenStore: TokenStoreProtocol { - private let accessToken: String - private let refreshToken: String + private var accessToken: String? + private var refreshToken: String? init(accessToken: String, refreshToken: String) { self.accessToken = accessToken @@ -179,19 +182,49 @@ actor ExplicitTokenStore: TokenStoreProtocol { } func setTokens(accessToken: String?, refreshToken: String?) async { - // Explicit tokens are immutable + // Store refreshed tokens in memory to prevent infinite refresh loops + if let accessToken = accessToken { + self.accessToken = accessToken + } + if let refreshToken = refreshToken { + self.refreshToken = refreshToken + } } func clearTokens() async { - // Explicit tokens are immutable + self.accessToken = nil + self.refreshToken = nil } } // MARK: - Null Token Store +/// Token store with no initial tokens. +/// Still stores any refreshed tokens in memory to prevent infinite refresh loops. actor NullTokenStore: TokenStoreProtocol { - func getAccessToken() async -> String? { nil } - func getRefreshToken() async -> String? { nil } - func setTokens(accessToken: String?, refreshToken: String?) async {} - func clearTokens() async {} + private var accessToken: String? + private var refreshToken: String? + + func getAccessToken() async -> String? { + return accessToken + } + + func getRefreshToken() async -> String? { + return refreshToken + } + + func setTokens(accessToken: String?, refreshToken: String?) async { + // Store refreshed tokens in memory to prevent infinite refresh loops + if let accessToken = accessToken { + self.accessToken = accessToken + } + if let refreshToken = refreshToken { + self.refreshToken = refreshToken + } + } + + func clearTokens() async { + self.accessToken = nil + self.refreshToken = nil + } } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index bc073ea92b..703327ac49 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import StackAuth /// Shared test configuration diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 510b1a986d..3c26f9606f 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -166,25 +166,48 @@ TokenStoreInit is a union type representing the different ways to provide token ```ts TokenStoreInit = | "cookie" // [JS-ONLY] Browser cookies + | "keychain" // [APPLE-ONLY] Secure Keychain storage | "memory" // In-memory storage | { accessToken: string, refreshToken: string } // Explicit tokens | RequestLike // Extract from request headers | null // No storage ``` +### Token Store Refresh Behavior + +IMPORTANT: ALL token stores (except "memory" and "cookie" which handle this naturally) +MUST save refreshed tokens in memory after initialization. When the access token expires +and gets refreshed, the new tokens must be stored and returned on subsequent calls. +Otherwise, the old expired token would still be returned, causing an infinite refresh loop. + +This applies to: +- Explicit tokens ({ accessToken, refreshToken }) +- RequestLike objects +- null (if tokens are set via refresh) + +These stores should behave like "memory" after initialization, just with pre-populated +(or empty) initial values. + ### Token Store Types "cookie": [JS-ONLY] Store tokens in browser cookies. Requires browser environment. Due to cookie complexity (Secure flags, SameSite, Partitioned/CHIPS, HTTPS detection), - this is only implemented in the JS SDK. Other SDKs should use "memory" or explicit tokens. + this is only implemented in the JS SDK. Other SDKs should use "memory", "keychain", + or explicit tokens. + +"keychain": [APPLE-ONLY] + Store tokens in the system Keychain (iOS, macOS, watchOS, tvOS, visionOS). + Tokens persist securely across app launches and are protected by the OS. + Only available on Apple platforms via the Security framework. + This is the recommended default for iOS/macOS apps. "memory": Store tokens in runtime memory. Lost on page refresh or process restart. Useful for short-lived sessions, CLI tools, or server-side scripts. { accessToken, refreshToken } object: - Use explicit token values directly. + Initialize with explicit token values. For custom token management scenarios. RequestLike object: