From 2bf31c379ca33e51622de3d5af38809fd212865c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 28 Jul 2026 21:49:42 -0400 Subject: [PATCH] feat(types)!: adopt @seamless-auth/types for the wire contract The SDK's request and response types were hand-written and maintained in parallel with the auth API's schemas, so they could drift from what the API actually sends. Credential.lastUsedAt was the proof: typed Date | null while the API serializes an ISO string, so adopter code calling a Date method typechecked and then threw. Alias the published contract instead. The dependency is types-only, imported with import type, so Zod never reaches the browser bundle and the export names adopters import are unchanged. The OAuth code list stays a compile-time Record for the same reason: it fails to build if the upstream union changes, without importing the runtime list. LoginStartResult and OrganizationSwitchResult Omit the token, subject, and session id the API returns, keeping the existing decision that sessions are carried by cookies and adopters never handle raw tokens. Several types are now more accurate, which is breaking at the type level: credential timestamps are strings, several credential fields are optional, User.phone is nullable, and User.roles is required. tests/wireTypes.test.ts pins these at compile time so a regression fails the build. Closes #115 Closes #116 --- .changeset/olive-carrots-repeat.md | 14 +++ AGENTS.md | 29 ++++- README.md | 22 ++++ package-lock.json | 26 ++++- package.json | 1 + src/client/createSeamlessAuthClient.ts | 140 +++++++++---------------- src/client/errors.ts | 26 +++-- src/session/createAuthSession.ts | 4 +- src/types.ts | 66 ++++-------- tests/wireTypes.test.ts | 67 ++++++++++++ 10 files changed, 247 insertions(+), 148 deletions(-) create mode 100644 .changeset/olive-carrots-repeat.md create mode 100644 tests/wireTypes.test.ts 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); + }); +});