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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/olive-carrots-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@seamless-auth/react': minor
---

Adopt `@seamless-auth/types` for the API request and response shapes. The SDK's types were hand-written and maintained in parallel with the auth API's schemas; they are now aliases of the published contract, so they cannot drift from what the API actually sends. The dependency is types-only, imported with `import type`, so no schema validation library reaches your bundle and the export names you import are unchanged.

Some types are now more accurate, which is a breaking change at the type level for adopters:

- `Credential.lastUsedAt` is `string | null | undefined`, not `Date | null`. The API serializes it as an ISO 8601 string, so code calling a `Date` method on it was relying on a type that never matched the wire value and threw at runtime. Wrap it yourself: `new Date(credential.lastUsedAt)`.
- `Credential.deviceType`, `friendlyName`, `platform`, `browser`, and `deviceInfo` are optional, matching the API. `Credential.createdAt` is now present.
- `User.phone` is `string | null`, and `User.roles` is required rather than optional. `User` also carries `lastLogin`.
- `Organization.createdAt` and `updatedAt` are `string` rather than `string | Date`.

No runtime behavior changes.
29 changes: 28 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,33 @@ Important implication:
- frontend behavior here is tightly coupled to backend route names and cookie/session expectations
- if request paths or auth flow ordering seem questionable, inspect `seamless-auth-server` or `seamless-auth-api` before changing code or docs

## Wire Types

Request and response shapes come from `@seamless-auth/types`, which is generated
from the auth API's schemas. `src/types.ts` and the type declarations in
`src/client/createSeamlessAuthClient.ts` alias that package rather than
redeclaring shapes.

Rules for this dependency:

- types only. Import with `import type` so the package's Zod dependency never
reaches the browser bundle. There is a `Record<OAuthErrorCode, true>` in
`src/client/errors.ts` that exists for exactly this reason: it is a
compile-time membership check standing in for the upstream runtime list.
- keep the SDK's own export names. Adopters import `Credential` from this
package, so alias upstream shapes to local names instead of re-exporting
theirs.
- session material stays unexposed. `LoginStartResult` and
`OrganizationSwitchResult` `Omit` the token, subject, and session id the API
returns, because sessions are carried by cookies here.
- a few shapes have upstream schemas but no exported type alias
(`OAuthProvidersResponse`, `CredentialUpdateResponse`, and the organization
envelope responses). Those stay declared locally until the package exports
them.

The PRF helper types and `SeamlessAuthResult` stay local: they are SDK concerns,
not wire contracts.

## Current Public API

`src/index.ts` is the authoritative export list. Treat the enumeration below as a
Expand Down Expand Up @@ -171,7 +198,7 @@ The current package is organized around a shared SDK core with optional UI layer
- `src/fetchWithAuth.ts`
- `/auth` request construction
- `src/types.ts`
- shared user and credential types
- aliases of the wire contract in `@seamless-auth/types`, not hand-written shapes
- `tests/*`
- Jest + Testing Library coverage for provider, client, hooks, and views

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,21 @@ The headless client exposes helpers for:
- logout and delete-user
- credential update and deletion

### Where the response types come from

The request and response types are aliases of
[`@seamless-auth/types`](https://www.npmjs.com/package/@seamless-auth/types), which is generated from
the auth API's schemas. `User`, `Credential`, `Organization`, `StepUpStatus`, `MessageResult`, and the
other wire shapes describe what the API actually sends, rather than a second copy maintained here that
could drift from it.

The dependency is types-only. Nothing from it is imported at runtime, so no schema validation library
reaches your bundle. Names exported from this package stay the SDK's own, so you keep importing
`Credential` from `@seamless-auth/react`.

Two SDK concerns are deliberately not shared, because they are not wire contracts: the PRF helper
types and the `SeamlessAuthResult` wrapper.

### Result convention

Every request method resolves to a `SeamlessAuthResult<T>`:
Expand Down Expand Up @@ -788,6 +803,13 @@ function PasskeyList() {
}
```

`Credential.lastUsedAt` and `Credential.createdAt` are ISO 8601 strings, which is what the API sends.
Wrap them yourself to format:

```tsx
const lastUsed = credential.lastUsedAt ? new Date(credential.lastUsedAt) : null;
```

Removing a passkey is a sensitive change. Gate it behind a fresh step-up when the account has other
factors, using `refreshStepUpStatus()` and `verifyStepUpWithPasskey()` from the step-up section.

Expand Down
26 changes: 24 additions & 2 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"typescript-eslint": "^8.46.1"
},
"dependencies": {
"@seamless-auth/types": "^0.2.0",
"@simplewebauthn/browser": "^13.1.0",
"eslint-plugin-license-header": "^0.9.0",
"libphonenumber-js": "^1.12.7",
Expand Down
140 changes: 52 additions & 88 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,29 @@ import {
WebAuthnError,
} from '@simplewebauthn/browser';

import type {
AddOrganizationMemberRequest,
CreateOrganizationRequest,
LoginMethod as LoginMethodShape,
LoginSuccessResponse,
LogoutScope as LogoutScopeShape,
MeResponse,
MessageResponse,
OrganizationListResponse,
OrganizationSwitchResponse,
PublicOAuthProvider,
RegistrationRequest,
StartOAuthLoginResponse,
StepUpMethod as StepUpMethodShape,
StepUpStatus as StepUpStatusShape,
TotpEnrollmentStartResponse,
TotpStatus as TotpStatusShape,
UpdateOrganizationMemberRequest,
UpdateOrganizationRequest,
} from '@seamless-auth/types';

import { createFetchWithAuth } from '../fetchWithAuth';
import { Credential, Organization, OrganizationMembership, User } from '../types';
import { Credential, Organization, OrganizationMembership } from '../types';
import { getWebAuthnErrorDetail } from './errors';
import {
NETWORK_ERROR_STATUS,
Expand Down Expand Up @@ -44,20 +65,18 @@ export interface LoginInput {
passkeyAvailable: boolean;
}

export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth';
export type LoginMethod = LoginMethodShape;

export interface LoginStartResult {
message?: string;
identifierType?: 'email' | 'phone';
loginMethods?: LoginMethod[];
}
/**
* The login response minus its session material. The API returns a token and
* subject here, which this SDK deliberately does not surface: sessions are
* carried by cookies, so adopters have no reason to handle raw tokens.
*/
export type LoginStartResult = Omit<LoginSuccessResponse, 'token' | 'sub'>;

export interface RegisterInput {
email: string;
// Registration only needs an email. A phone can be added and verified later,
// so it is optional here and only sent when a caller supplies one.
phone?: string | null;
}
// Registration only needs an email. A phone can be added and verified later, so
// it is optional on the wire and only sent when a caller supplies one.
export type RegisterInput = RegistrationRequest;

export interface PasskeyMetadata {
friendlyName: string;
Expand All @@ -66,41 +85,17 @@ export interface PasskeyMetadata {
deviceInfo: string;
}

export interface CurrentUserResult {
user: User;
credentials: Credential[];
organizations?: Organization[];
activeOrganization?: Organization | null;
}
export type CurrentUserResult = MeResponse;

export interface CreateOrganizationInput {
name: string;
slug?: string;
metadata?: Record<string, unknown> | null;
}
export type CreateOrganizationInput = CreateOrganizationRequest;

export interface UpdateOrganizationInput {
name?: string;
slug?: string;
metadata?: Record<string, unknown> | null;
}
export type UpdateOrganizationInput = UpdateOrganizationRequest;

export interface OrganizationMemberInput {
userId?: string;
email?: string;
roles?: string[];
scopes?: string[];
}
export type OrganizationMemberInput = AddOrganizationMemberRequest;

export interface OrganizationMemberUpdateInput {
roles?: string[];
scopes?: string[];
}
export type OrganizationMemberUpdateInput = UpdateOrganizationMemberRequest;

export interface OrganizationsResult {
organizations: Organization[];
activeOrganizationId?: string | null;
}
export type OrganizationsResult = OrganizationListResponse;

export interface OrganizationResult {
organization: Organization;
Expand All @@ -117,21 +112,15 @@ export interface OrganizationMembershipResult {
}

/**
* Response body when the active organization changes. The server also returns
* session material here, which this SDK deliberately does not surface: sessions
* are carried by cookies, so adopters have no reason to handle raw tokens.
* Response body when the active organization changes, minus its session
* material. See `LoginStartResult` for why the token and subject are dropped.
*/
export interface OrganizationSwitchResult {
message: string;
organizationId: string;
organization: Organization;
}
export type OrganizationSwitchResult = Omit<
OrganizationSwitchResponse,
'token' | 'sub' | 'sessionId'
>;

export interface OAuthProvider {
id: string;
name: string;
scopes: string[];
}
export type OAuthProvider = PublicOAuthProvider;

export interface OAuthProvidersResult {
providers: OAuthProvider[];
Expand All @@ -143,11 +132,7 @@ export interface StartOAuthLoginInput {
returnTo?: string;
}

export interface StartOAuthLoginResult {
provider: OAuthProvider;
state: string;
authorizationUrl: string;
}
export type StartOAuthLoginResult = StartOAuthLoginResponse;

export interface FinishOAuthLoginInput {
providerId: string;
Expand All @@ -156,9 +141,7 @@ export interface FinishOAuthLoginInput {
}

/** Response body for endpoints that only acknowledge the request. */
export interface MessageResult {
message: string;
}
export type MessageResult = MessageResponse;

/** Payload returned by a completed passkey login. */
export interface PasskeyLoginData {
Expand All @@ -183,32 +166,13 @@ export interface RegisterPasskeyOptions {
requirePrf?: boolean;
}

export type StepUpMethod = 'webauthn' | 'totp';
export type StepUpMethod = StepUpMethodShape;

export interface TotpStatus {
enabled: boolean;
verifiedAt: string | null;
lastUsedAt: string | null;
}
export type TotpStatus = TotpStatusShape;

export interface TotpEnrollmentStartResult {
message: string;
secret: string;
otpauthUrl: string;
issuer: string;
accountName: string;
algorithm: string;
digits: number;
period: number;
}
export type TotpEnrollmentStartResult = TotpEnrollmentStartResponse;

export interface StepUpStatus {
fresh: boolean;
method: StepUpMethod | null;
verifiedAt: string | null;
expiresAt: string | null;
maxAgeSeconds: number;
}
export type StepUpStatus = StepUpStatusShape;

export interface PasskeyLoginOptions {
prf?: PasskeyPrfInput;
Expand All @@ -220,7 +184,7 @@ export interface StepUpPrfData extends StepUpStatus {
prf: PasskeyPrfResult;
}

export type LogoutScope = 'current_session' | 'all_sessions';
export type LogoutScope = LogoutScopeShape;

export interface LogoutOptions {
scope?: LogoutScope;
Expand Down
Loading
Loading