diff --git a/.changeset/olive-carrots-repeat.md b/.changeset/olive-carrots-repeat.md new file mode 100644 index 0000000..600e3f9 --- /dev/null +++ b/.changeset/olive-carrots-repeat.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index ab20693..0f92428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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 @@ -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 diff --git a/README.md b/README.md index c9aa28e..9f69ce3 100644 --- a/README.md +++ b/README.md @@ -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`: @@ -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. diff --git a/package-lock.json b/package-lock.json index 37fa3fc..920a58e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@seamless-auth/react", - "version": "0.4.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/react", - "version": "0.4.0", + "version": "0.6.0", "license": "AGPL-3.0-only", "dependencies": { + "@seamless-auth/types": "^0.2.0", "@simplewebauthn/browser": "^13.1.0", "eslint-plugin-license-header": "^0.9.0", "libphonenumber-js": "^1.12.7", @@ -3064,6 +3065,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@seamless-auth/types": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.2.0.tgz", + "integrity": "sha512-4QHdkZLKFNvU8g//nDU983rmIXNsnwIPj/eXsCWmj6+FAgCcEaHhmE/U7u3ogGk7+ZuwRWagQ8aBjHqoWggovg==", + "license": "AGPL-3.0-only", + "dependencies": { + "zod": "^4.3.6" + }, + "engines": { + "node": ">=24 <25" + } + }, "node_modules/@simplewebauthn/browser": { "version": "13.2.2", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", @@ -13751,6 +13764,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index eff4c56..6f2dbaf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 5a5afbd..b932f23 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -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, @@ -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; -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; @@ -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 | null; -} +export type CreateOrganizationInput = CreateOrganizationRequest; -export interface UpdateOrganizationInput { - name?: string; - slug?: string; - metadata?: Record | 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; @@ -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[]; @@ -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; @@ -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 { @@ -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; @@ -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; diff --git a/src/client/errors.ts b/src/client/errors.ts index f472a6a..082ada5 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -4,6 +4,8 @@ * See LICENSE file in the project root for full license information */ +import type { OAuthErrorCode as OAuthErrorCodeShape } from '@seamless-auth/types'; + /** * Error carrying the auth server's response detail, so callers can map known * failures to their own messaging instead of only seeing a generic string. @@ -30,16 +32,19 @@ export class SeamlessAuthError extends Error { * Machine-readable codes the auth API returns alongside `error` on an OAuth * callback failure the user can act on. */ -export type OAuthErrorCode = - | 'oauth_missing_email' - | 'oauth_email_not_verified' - | 'oauth_missing_subject'; +export type OAuthErrorCode = OAuthErrorCodeShape; -const OAUTH_ERROR_CODES = new Set([ - 'oauth_missing_email', - 'oauth_email_not_verified', - 'oauth_missing_subject', -]); +/* + * The upstream package exports a runtime list of these codes too, but importing + * it would pull Zod into the browser bundle for what is a membership test. This + * is the type-only equivalent: `Record` fails to compile + * if the upstream union gains or loses a member, so it cannot drift silently. + */ +const OAUTH_ERROR_CODES: Record = { + oauth_missing_email: true, + oauth_email_not_verified: true, + oauth_missing_subject: true, +}; /** * Read the OAuth failure code off a result error. Returns `undefined` for @@ -57,7 +62,8 @@ export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined { const { code } = error.body as { code?: unknown }; - return typeof code === 'string' && OAUTH_ERROR_CODES.has(code) + return typeof code === 'string' && + Object.prototype.hasOwnProperty.call(OAUTH_ERROR_CODES, code) ? (code as OAuthErrorCode) : undefined; } diff --git a/src/session/createAuthSession.ts b/src/session/createAuthSession.ts index 80c1544..e947b7e 100644 --- a/src/session/createAuthSession.ts +++ b/src/session/createAuthSession.ts @@ -244,7 +244,9 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { updateCredential: async credential => { const { data, error } = await client.updateCredential({ - friendlyName: credential.friendlyName, + // The wire type leaves the name optional, and the update endpoint reads + // null as "clear it", so an absent name is sent as an explicit null. + friendlyName: credential.friendlyName ?? null, id: credential.id, }); diff --git a/src/types.ts b/src/types.ts index e1eeda9..c1d946b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,53 +4,27 @@ * See LICENSE file in the project root for full license information */ -import { - AuthenticatorTransportFuture, - CredentialDeviceType, -} from '@simplewebauthn/browser'; +import type { + CredentialResponse, + MeUser, + Organization as OrganizationShape, + OrganizationMembership as OrganizationMembershipShape, +} from '@seamless-auth/types'; -export interface User { - id: string; - email: string; - phone: string; - roles?: string[]; - activeOrganizationId?: string | null; -} +/* + * The wire contract lives in `@seamless-auth/types`, which is generated from the + * auth API's Zod schemas. These names are the SDK's public vocabulary, so they + * stay, but they are aliases now rather than a second hand-maintained copy that + * can drift from what the API actually sends. + * + * Types only: nothing here is imported at runtime, so Zod never reaches the + * browser bundle. + */ + +export type User = MeUser; -export interface OrganizationMembership { - id: string; - organizationId: string; - userId: string; - roles: string[]; - scopes: string[]; - createdAt: string | Date; - updatedAt: string | Date; - user?: User; -} +export type OrganizationMembership = OrganizationMembershipShape; -export interface Organization { - id: string; - name: string; - slug: string; - createdByUserId: string | null; - metadata: Record | null; - createdAt: string | Date; - updatedAt: string | Date; - membership?: OrganizationMembership; - memberCount?: number; -} +export type Organization = OrganizationShape; -export interface Credential { - id: string; - counter: number; - transports?: AuthenticatorTransportFuture[]; - deviceType: CredentialDeviceType; - backedup: boolean; - backedUp?: boolean; - prfCapable?: boolean; - friendlyName: string | null; - lastUsedAt: Date | null; - platform: string | null; - browser: string | null; - deviceInfo: string | null; -} +export type Credential = CredentialResponse; diff --git a/tests/wireTypes.test.ts b/tests/wireTypes.test.ts new file mode 100644 index 0000000..f82b510 --- /dev/null +++ b/tests/wireTypes.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import type { Credential, Organization, User } from '@/types'; +import type { + LoginStartResult, + MessageResult, + OrganizationSwitchResult, + StepUpStatus, +} from '@/client/createSeamlessAuthClient'; + +/* + * These assertions run at compile time, not at runtime: `npm run typecheck` + * covers tests/, so a shape drifting back from the published wire contract fails + * the build rather than reaching adopters as a type that lies about the payload. + */ +describe('wire types match what the API sends', () => { + it('types credential timestamps as ISO strings, not Dates', () => { + const lastUsedAt: string | null | undefined = null as Credential['lastUsedAt']; + const createdAt: string = '' as Credential['createdAt']; + + // The bug this replaced: `Date | null` let adopters call Date methods on a + // string, which typechecked and then threw. + // @ts-expect-error a Date is not assignable to the wire type + const wrong: Credential['lastUsedAt'] = new Date(); + + expect([lastUsedAt, createdAt, wrong]).toHaveLength(3); + }); + + it('types the user shape the way the API serializes it', () => { + const phone: string | null = null as User['phone']; + const roles: string[] = [] as User['roles']; + + // @ts-expect-error roles is required, so it cannot be undefined + const wrong: User['roles'] = undefined; + + expect([phone, roles, wrong]).toHaveLength(3); + }); + + it('types organization timestamps as strings', () => { + const createdAt: string = '' as Organization['createdAt']; + + expect(typeof createdAt).toBe('string'); + }); + + // Sessions are carried by cookies, so the SDK never surfaces the token, subject, + // or session id the API returns alongside these payloads. + it('keeps session material off the public result types', () => { + const login = {} as LoginStartResult; + const organizationSwitch = {} as OrganizationSwitchResult; + + // @ts-expect-error the login result must not advertise a token + expect(login.token).toBeUndefined(); + // @ts-expect-error the switch result must not advertise a session id + expect(organizationSwitch.sessionId).toBeUndefined(); + }); + + it('keeps the acknowledgement and step-up shapes intact', () => { + const message: string = '' as MessageResult['message']; + const method: 'webauthn' | 'totp' | null = null as StepUpStatus['method']; + + expect([message, method]).toHaveLength(2); + }); +});