@softwarepatterns/am 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,58 @@
1
- import type { AuthenticationResult, ClientId, EmailCheckStatus, LoginMethod, ProblemDetails, SessionProfile, SessionTokens, UserId, UserResource } from "./types";
1
+ import type { Authentication, ClientId, EmailCheckStatus, LoginMethod, ProblemDetails, SessionProfile, SessionTokens, StorageLike } from "./types";
2
+ type StorageConfig = StorageLike | "localStorage" | null | undefined;
2
3
  type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
4
+ type Config = {
5
+ baseUrl: string;
6
+ earlyRefreshMs: number;
7
+ fetchFn: FetchFn;
8
+ profileStorageKey: string;
9
+ storage: StorageConfig;
10
+ tokensStorageKey: string;
11
+ };
12
+ type AuthEventMap = {
13
+ refresh: SessionTokens;
14
+ profileChange: SessionProfile;
15
+ unauthenticated: AuthError;
16
+ sessionChange: AuthSession | null;
17
+ };
18
+ /**
19
+ * AuthError represents structured authentication failures from Accountmaker endpoints.
20
+ *
21
+ * AuthError wraps RFC 7807 Problem Details. invalidParams may be present for field-level validation.
22
+ * Network failures throw other error types.
23
+ *
24
+ * Also note that the `type` field often contains a URI that points to documentation about the
25
+ * specific error type, including how to resolve it, code samples, and links to the RFCs or other
26
+ * standards that define the error.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * try {
31
+ * const session = await am.signIn({ email: 'test@example.com', password: 'password123' });
32
+ * } catch (e) {
33
+ * if (e instanceof AuthError) {
34
+ * console.error("Authentication failed:", e.title);
35
+ * if (e.invalidParams) {
36
+ * for (const param of e.invalidParams) {
37
+ * console.error(` - Invalid parameter: ${param.path} (${param.type})`);
38
+ * }
39
+ * }
40
+ * } else {
41
+ * console.error("Unexpected error:", e);
42
+ * }
43
+ * }
44
+ * ```
45
+ *
46
+ * Note that HTTP error codes are distinctly:
47
+ * - 400: Client error (bad request, invalid input, etc.)
48
+ * - 401: Unauthenticated (we don't know who you are)
49
+ * - 402: Payment required (e.g. billing issue)
50
+ * - 403: Unauthorized (we know who you are, but you don't have permission)
51
+ * - 404: Not found
52
+ * - 409: Conflict (email already registered, user already invited, etc.)
53
+ * - 429: Too many requests (rate limiting)
54
+ * - 500: Internal server error (server's fault)
55
+ */
3
56
  export declare class AuthError extends Error {
4
57
  readonly problem: ProblemDetails;
5
58
  constructor(problem: ProblemDetails);
@@ -10,57 +63,140 @@ export declare class AuthError extends Error {
10
63
  get detail(): string | undefined;
11
64
  get invalidParams(): ProblemDetails["invalidParams"] | undefined;
12
65
  }
13
- type Config = {
14
- fetchFn: FetchFn;
15
- baseUrl: string;
16
- };
66
+ /**
67
+ * AuthSession represents an authenticated user with automatic token refresh and persisted state.
68
+ *
69
+ * AuthSession owns tokens, profile data, refresh logic, and authenticated requests.
70
+ */
17
71
  export declare class AuthSession {
18
- private tokens;
19
- private config;
20
- private lastUpdated;
21
- constructor(tokens: SessionTokens, config: Partial<Config>);
22
- get accessToken(): string;
23
- get refreshToken(): string;
24
- get idToken(): string | undefined;
25
- get tokenType(): "Bearer";
26
- get expiresIn(): number;
27
- get lastUpdatedAt(): Date;
28
- get expiresAt(): Date;
72
+ constructor(initial: Authentication, config: Partial<Config>);
73
+ /**
74
+ * Removes all persisted data (tokens, profile) from storage, and prevents future
75
+ * refreshes of token and profile data. Does NOT clear current token or profile data from the
76
+ * session memory, but effectively deactivates the session for future use.
77
+ */
78
+ clear(): void;
79
+ get tokens(): SessionTokens;
80
+ get profile(): SessionProfile;
81
+ toJSON(): Authentication;
82
+ /**
83
+ * Creates an AuthSession from existing authentication data. Useful for restoring
84
+ * a session from custom storage or creating a session from custom server-provided data.
85
+ */
86
+ static fromJSON(initial: Authentication, config: Partial<Config>): AuthSession;
87
+ /**
88
+ * Returns true if the access token is expired or will expire soon. The
89
+ * "soon" threshold is configured via Config.earlyRefreshMs (default 1 minute).
90
+ */
29
91
  isExpired(): boolean;
30
92
  /**
31
- * Fetch with automatic token refresh.
93
+ * Performs standard fetch() with a Bearer token and automatic refresh.
94
+ *
95
+ * Returns Response, does not parse the body, does not throw for HTTP status codes.
96
+ * Throws AuthError when refresh fails, trows runtime errors on network failure.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const res = await session.fetch("/api/projects");
101
+ * const projects = await res.json();
102
+ * ```
103
+ *
104
+ * Any service can validate tokens using:
105
+ * https://api.accountmaker.com/.well-known/jwks.json?client_id={clientId}
106
+ */
107
+ fetch(url: string | URL, init?: RequestInit): Promise<Response>;
108
+ /**
109
+ * Replaces the access token using the refresh token.
32
110
  *
33
- * If the access token is expired, it will be refreshed before making the request. The
34
- * Authorization header will be set with the current access token.
111
+ * It is called automatically by all other methods when the access token is expired or near expiry.
35
112
  */
36
- fetch(url: string | URL | Request, init?: RequestInit): Promise<Response>;
37
113
  refresh(): Promise<void>;
114
+ /**
115
+ * refetchProfile() replaces the cached profile with server state.
116
+ *
117
+ * Concurrent calls are deduplicated.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * await session.refetchProfile();
122
+ * console.log("Updated profile:", session.profile);
123
+ * ```
124
+ *
125
+ * Throws AuthError on network errors, etc.
126
+ * @throws AuthError
127
+ */
128
+ refetchProfile(): Promise<void>;
129
+ /**
130
+ * Requests a verification email for the current user.
131
+ *
132
+ * Throws AuthError on network errors, etc.
133
+ * @throws AuthError
134
+ */
38
135
  sendVerificationEmail(): Promise<void>;
39
- me(): Promise<SessionProfile>;
40
- user(id: UserId): Promise<UserResource>;
41
136
  }
137
+ /**
138
+ * Am runs authentication flows and produces AuthSession.
139
+ *
140
+ * Use Am before a session exists (sign-in, sign-up, magic link, invites, password reset, CSRF).
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * const am = new Am();
145
+ * const session = await am.signIn({ email: 'user@example.com', password: 'secret' });
146
+ * // Now use `session` for protected API calls
147
+ * ```
148
+ */
42
149
  export declare class Am {
43
- private options;
44
150
  constructor(config?: Partial<Config>);
45
- static createAuthSession(tokens: SessionTokens, config?: Partial<Config>): AuthSession;
46
- createAuthSession(tokens: SessionTokens): AuthSession;
47
- static acceptInvite(query: {
48
- clientId: ClientId;
49
- token: string;
50
- }, config?: Partial<Config>): Promise<AuthenticationResult>;
151
+ /**
152
+ * session returns the current AuthSession or null.
153
+ *
154
+ * Use restoreSession() to load from storage.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * const session = am.session;
159
+ * if (!session) throw new Error("Not authenticated");
160
+ * ```
161
+ */
162
+ get session(): AuthSession | null;
163
+ /**
164
+ * Constructs AuthSession from existing tokens and profile.
165
+ *
166
+ * Use this after a server-side auth handshake or custom persistence.
167
+ */
168
+ createSession(initial: Authentication): AuthSession;
169
+ /**
170
+ * restoreSession loads AuthSession from storage or returns null.
171
+ *
172
+ * Invalid or partial stored data is cleared.
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * const am = new Am({ storage: 'localStorage' });
177
+ * const session = am.restoreSession();
178
+ * if (session) session.fetch("/api/me");
179
+ * ```
180
+ */
181
+ restoreSession(): AuthSession | null;
182
+ /**
183
+ * Subscribes to auth events and returns an unsubscribe function.
184
+ */
185
+ on<K extends keyof AuthEventMap>(event: K, fn: (v: AuthEventMap[K]) => void): () => boolean;
186
+ /**
187
+ * acceptInvite exchanges an invite token for a fresh AuthSession.
188
+ *
189
+ * Throws AuthError on invalid, expired, or already-used tokens.
190
+ */
51
191
  acceptInvite(query: {
52
192
  clientId: ClientId;
53
193
  token: string;
54
- }): Promise<AuthenticationResult>;
55
- static checkEmail(body: {
56
- clientId: ClientId;
57
- email: string;
58
- csrfToken?: string;
59
- }, config?: Partial<Config>): Promise<{
60
- status: EmailCheckStatus;
61
- preferred: LoginMethod[];
62
- available: LoginMethod[];
63
- }>;
194
+ }): Promise<AuthSession>;
195
+ /**
196
+ * checkEmail returns how an email should authenticate for this client.
197
+ *
198
+ * Use this to choose password vs magic link vs SSO before rendering a login form.
199
+ */
64
200
  checkEmail(body: {
65
201
  clientId: ClientId;
66
202
  email: string;
@@ -70,63 +206,70 @@ export declare class Am {
70
206
  preferred: LoginMethod[];
71
207
  available: LoginMethod[];
72
208
  }>;
73
- static csrfSession(config?: Partial<Config>): Promise<{
74
- csrfToken: string;
75
- }>;
209
+ /**
210
+ * Sets the httpOnly CSRF cookie.
211
+ *
212
+ * Call csrfToken() next to fetch a signed token for form posts.
213
+ */
76
214
  csrfSession(): Promise<{
77
215
  csrfToken: string;
78
216
  }>;
79
- static csrfToken(config?: Partial<Config>): Promise<{
80
- csrfToken: string;
81
- }>;
217
+ /**
218
+ * Returns a signed CSRF token for form posts.
219
+ *
220
+ * Call csrfSession() first to set the CSRF cookie.
221
+ */
82
222
  csrfToken(): Promise<{
83
223
  csrfToken: string;
84
224
  }>;
85
- static login(body: {
86
- email: string;
87
- password: string;
88
- csrfToken?: string;
89
- }, config?: Partial<Config>): Promise<AuthenticationResult>;
90
- login(body: {
91
- email: string;
92
- password: string;
93
- csrfToken?: string;
94
- }): Promise<AuthenticationResult>;
95
- static tokenLogin(token: string, config?: Partial<Config>): Promise<AuthenticationResult>;
96
- tokenLogin(token: string): Promise<AuthenticationResult>;
97
- static refresh(refreshToken: string, config?: Partial<Config>): Promise<SessionTokens>;
98
- refresh(refreshToken: string): Promise<SessionTokens>;
99
- static register(body: {
225
+ /**
226
+ * Authenticates with email and password and returns a new AuthSession.
227
+ *
228
+ * Tokens and profile are persisted when storage is configured.
229
+ */
230
+ signIn(body: {
231
+ clientId: ClientId;
100
232
  email: string;
101
233
  password: string;
102
234
  csrfToken?: string;
103
- }, config?: Partial<Config>): Promise<AuthenticationResult>;
104
- register(body: {
235
+ }): Promise<AuthSession>;
236
+ /**
237
+ * Authenticates with a one-time token and returns a new AuthSession.
238
+ *
239
+ * Use this for magic links and similar one-time login flows.
240
+ */
241
+ signInWithToken(token: string): Promise<AuthSession>;
242
+ /**
243
+ * Creates a new user and returns a new AuthSession.
244
+ *
245
+ * Tokens and profile are persisted when storage is configured.
246
+ */
247
+ signUp(body: {
248
+ clientId: ClientId;
105
249
  email: string;
106
250
  password: string;
107
251
  csrfToken?: string;
108
- }): Promise<AuthenticationResult>;
109
- static resetPassword(body: {
110
- token: string;
111
- newPassword: string;
112
- }, config?: Partial<Config>): Promise<void>;
252
+ }): Promise<AuthSession>;
253
+ /**
254
+ * Sets a new password using a one-time reset token.
255
+ */
113
256
  resetPassword(body: {
114
257
  token: string;
115
258
  newPassword: string;
116
259
  }): Promise<void>;
117
- static sendMagicLink(body: {
118
- email: string;
119
- csrfToken?: string;
120
- }, config?: Partial<Config>): Promise<void>;
260
+ /**
261
+ * Sends a one-time sign-in link to an email address.
262
+ */
121
263
  sendMagicLink(body: {
264
+ clientId: ClientId;
122
265
  email: string;
123
266
  csrfToken?: string;
124
267
  }): Promise<void>;
125
- static sendPasswordReset(body: {
126
- email: string;
127
- csrfToken?: string;
128
- }, config?: Partial<Config>): Promise<void>;
268
+ /**
269
+ * Sends a password reset link to an email address.
270
+ */
129
271
  sendPasswordReset(body: {
272
+ clientId: ClientId;
130
273
  email: string;
131
274
  csrfToken?: string;
132
275
  }): Promise<void>;
@@ -1,2 +1,2 @@
1
- export { Am, AuthSession, AuthError } from "./auth";
2
- export type { AuthenticationResult, ClientId, EmailCheckStatus, LoginMethod, ProblemDetails, SessionProfile, SessionTokens, UserId, UserResource, } from "./types";
1
+ export * from "./auth";
2
+ export * from "./types";
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Standard error object following RFC 7807 (Problem Details for HTTP APIs).
3
+ *
4
+ * Returned in AuthError.problem when the server responds with application/problem+json.
5
+ *
6
+ * - `type`: A URI reference that identifies the problem type. Often links to human-readable documentation.
7
+ * - `title`: A short, human-readable summary of the problem type.
8
+ * - `status`: The HTTP status code.
9
+ * - `code`: Application-specific error code for programmatic handling. Maps to the final part of the error type.
10
+ * - `detail`: Human-readable explanation specific to this occurrence. Do not rely on this for programmatic handling.
11
+ * - `invalidParams`: Present on validation errors (typically 400). Provides field-level details
12
+ * for building precise UI feedback and can be used for programmatic handling. Each entry includes:
13
+ * - `in`: Location of the invalid parameter (body, cookie, header, query, path).
14
+ * - `path`: Dot-separated path to the invalid parameter.
15
+ * - `type`: Error type code for this parameter (e.g. "required", "email", "min_length").
16
+ * - `received`: The actual value received.
17
+ * - `expected`: (optional) Description of the expected value.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * catch (e) {
22
+ * if (e instanceof AuthError && e.invalidParams) {
23
+ * const errors = e.invalidParams.reduce((acc, p) => {
24
+ * acc[p.path] = p.type;
25
+ * return acc;
26
+ * }, {} as Record<string, string>);
27
+ * setFieldErrors(errors);
28
+ * }
29
+ * }
30
+ * ```
31
+ */
1
32
  export type ProblemDetails = {
2
33
  type: string;
3
34
  title: string;
@@ -12,36 +43,98 @@ export type ProblemDetails = {
12
43
  expected?: string;
13
44
  }[];
14
45
  };
46
+ /**
47
+ * Opaque identifier for a client application.
48
+ *
49
+ * Used to look up client-specific settings on how authentication should be handled.
50
+ *
51
+ * All client-specific operations (invites, branding, rate limits) are scoped to a ClientId.
52
+ */
15
53
  export type ClientId = `cid${string}`;
54
+ /** Identifier for a user. */
16
55
  export type UserId = `uid${string}`;
56
+ /** Identifier for an account (i.e., a billable entity). */
17
57
  export type AccountId = `acc${string}`;
58
+ /** Identifier for a membership linking a user to an account. */
18
59
  export type MembershipId = `mbr${string}`;
60
+ /**
61
+ * Possible statuses for an account.
62
+ *
63
+ * - active: Normal operation
64
+ * - trial: In trial period
65
+ * - past_due: Payment failed but grace period active
66
+ * - suspended: Access restricted due to billing or policy
67
+ * - closed: Permanently closed
68
+ */
19
69
  export type AccountStatus = "active" | "trial" | "past_due" | "suspended" | "closed";
70
+ /**
71
+ * Accounts are billable entities that can access resources. Users are linked to accounts via memberships with roles.
72
+ *
73
+ * An account with subaccounts is acting as a tenant, and is used to create services with their own billing,
74
+ * accounts, users, memberships, etc. This is useful for SaaS platforms that want to offer isolated environments
75
+ * for different customers, and to allow reselling of their services to other SaaS providers.
76
+ *
77
+ * For example, an email service lets customers sign up. However, some customers want to offer
78
+ * email services to their own clients. Therefore, the email provider has an account for each customer, and those
79
+ * customers have accounts for their own clients as well. Each level is isolated, with separate billing and users.
80
+ */
20
81
  export type AccountResource = {
21
82
  id: AccountId;
83
+ parentId: AccountId | null;
84
+ /** Display name chosen by the account owner */
22
85
  name: string | null;
86
+ /** URL to the account's avatar image */
23
87
  avatarUrl: string | null;
88
+ /** Current account status */
24
89
  status: AccountStatus;
90
+ /** ISO 8601 timestamp until which the account is paid (null if never paid or closed) */
25
91
  paidUntil: string | null;
26
92
  };
27
- export type UserStatus = "active" | "trial" | "past_due" | "suspended" | "closed";
93
+ /**
94
+ * Possible statuses for a user.
95
+ *
96
+ * - active: Normal operation
97
+ * - trial: In trial period
98
+ * - past_due: Payment failed but grace period active
99
+ * - suspended: Access restricted due to billing or policy
100
+ * - closed: Permanently closed
101
+ */
102
+ export type UserStatus = "active" | "disabled" | "suspended" | "deleted";
103
+ /**
104
+ * A reference to a user. Will not be deleted even due to GDPR requests or account closures,
105
+ * to maintain referential integrity in audit logs and historical records.
106
+ */
28
107
  export type UserResource = {
29
108
  id: UserId;
30
109
  accountId: AccountId;
31
110
  status: UserStatus;
32
111
  preferredMembershipId: MembershipId | null;
33
112
  };
113
+ /**
114
+ * Personal identity information (PII) for a user. Will be deleted due to GDPR requests or account
115
+ * closures to respect legal requirements of various regions..
116
+ */
34
117
  export type UserIdentity = {
35
118
  id: UserId;
36
119
  avatarUrl: string | null;
120
+ /** External identifier from third-party provider (i.e., SCIM) */
37
121
  externalId: string | null;
38
122
  givenName: string | null;
39
123
  familyName: string | null;
40
124
  displayName: string | null;
125
+ /** Preferred language code (en-CA, fr-FR, zh-CN, etc.) */
41
126
  preferredLanguage: string | null;
127
+ /** Locale code (e.g., "en-US") */
42
128
  locale: string | null;
43
129
  timezone: string | null;
44
130
  };
131
+ /**
132
+ * Roles within an account membership.
133
+ *
134
+ * - owner: Full administrative access, may perform destructive actions.
135
+ * - member: Standard non-destructive access
136
+ * - viewer: Read-only access
137
+ */
45
138
  export type MembershipRole = "owner" | "member" | "viewer";
46
139
  export type Membership = {
47
140
  id: MembershipId;
@@ -49,29 +142,70 @@ export type Membership = {
49
142
  accountId: AccountId;
50
143
  role: MembershipRole;
51
144
  };
145
+ /**
146
+ * Email credential record attached to a user.
147
+ *
148
+ * Sensitive fields (email) are only included when explicitly requested
149
+ * or when the caller has appropriate permissions.
150
+ */
52
151
  export type EmailCredential = {
53
152
  id: string;
54
153
  email: string | null;
55
- hashedEmail: string | null;
56
- hashedPassword: string | null;
57
154
  emailVerifiedAt: string | null;
58
155
  };
59
156
  export type SessionTokens = {
157
+ /** Signed JWT access token for authenticating API requests */
60
158
  accessToken: string;
159
+ /** Opaque refresh token for obtaining new access tokens */
61
160
  refreshToken: string;
62
161
  tokenType: "Bearer";
162
+ /** Seconds until the access token expires from time of issuance */
63
163
  expiresIn: number;
64
164
  idToken?: string;
165
+ /** Absolute expiration time (milliseconds since epoch) set by the client library */
166
+ expiresAt: number;
65
167
  };
168
+ /**
169
+ * Complete profile of the currently authenticated user.
170
+ *
171
+ * Combines basic user data with identity, credentials, memberships, and freshness timestamp.
172
+ *
173
+ * `lastUpdatedAt` is updated whenever the profile is fetched from the server.
174
+ */
66
175
  export type SessionProfile = UserResource & {
67
- identity: UserIdentity;
176
+ identity: UserIdentity | null;
68
177
  emailCredentials: EmailCredential[];
69
- memberships: Membership[];
178
+ memberships: (Membership & {
179
+ account: AccountResource;
180
+ })[];
181
+ /** Currently active membership in the accessToken (determined by preferredMembershipId or context) */
70
182
  activeMembership: Membership | null;
183
+ lastUpdatedAt: number;
71
184
  };
72
- export type AuthenticationResult = {
73
- session: SessionTokens;
185
+ /**
186
+ * Combined authentication state containing tokens and optional profile.
187
+ */
188
+ export type Authentication = {
189
+ tokens: SessionTokens;
74
190
  profile: SessionProfile;
75
191
  };
192
+ /**
193
+ * Result of checkEmail() indicating whether the email is registered.
194
+ */
76
195
  export type EmailCheckStatus = "active" | "inactive";
196
+ /**
197
+ * Supported login methods for an email address.
198
+ *
199
+ * Currently limited to email/password and magic link flows.
200
+ */
77
201
  export type LoginMethod = "email_password" | "magic_link";
202
+ /**
203
+ * Minimal storage interface required for session persistence.
204
+ *
205
+ * Compatible with localStorage, sessionStorage, AsyncStorage (React Native), or any custom implementation.
206
+ */
207
+ export type StorageLike = {
208
+ getItem(key: string): string | null;
209
+ setItem(key: string, value: string): void;
210
+ removeItem(key: string): void;
211
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softwarepatterns/am",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Auth client SDK for AccountMaker (Am)",
5
5
  "keywords": [
6
6
  "authentication",
@@ -47,8 +47,7 @@
47
47
  "test:unit": "bun test src",
48
48
  "test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun test test/integration",
49
49
  "typecheck": "bunx tsc --noEmit",
50
- "check": "npm run typecheck && npm run build",
51
- "digest": "bunx ai-digest -i . -o codebase.md --whitespace-removal --show-output-files"
50
+ "check": "npm run typecheck && npm run build"
52
51
  },
53
52
  "publishConfig": {
54
53
  "access": "public"