@marianmeres/ownsuite 2.0.0 → 2.2.1

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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @module adapters/mock-auth
3
+ *
4
+ * In-memory mock implementations of AuthAdapter and ProfileAdapter.
5
+ * Drives the unit tests for AuthManager / ProfileManager / SessionManager
6
+ * without a real server. Consumers can also use it to build demos or
7
+ * storybooks that exercise the full suite.
8
+ *
9
+ * Deliberately small: everything is held in a shared `Store` object the
10
+ * adapters close over, so tests can peek at or mutate state directly.
11
+ */
12
+ export function createMockAuthStore(init = {}) {
13
+ const store = {
14
+ accounts: new Map(),
15
+ requireVerifiedEmail: init.requireVerifiedEmail ?? false,
16
+ jwtsByEmail: new Map(),
17
+ };
18
+ for (const a of init.seed ?? []) {
19
+ store.accounts.set(a.email, { ...a });
20
+ }
21
+ return store;
22
+ }
23
+ function mintJwt(email) {
24
+ return `mock.${btoa(email)}.${Date.now().toString(36)}`;
25
+ }
26
+ export function createMockAuthAdapter(store) {
27
+ return {
28
+ register(input, _ctx) {
29
+ if (store.accounts.has(input.email)) {
30
+ return Promise.reject(Object.assign(new Error("Conflict"), { status: 409 }));
31
+ }
32
+ const roles = (input.roles ?? ["user"]).filter((r) => !["admin", "root", "guest"].includes(r));
33
+ const effectiveRoles = roles.length > 0 ? roles : ["user"];
34
+ const account = {
35
+ email: input.email,
36
+ password: input.password,
37
+ roles: effectiveRoles,
38
+ isVerified: false,
39
+ hasPassword: true,
40
+ oauthConnections: [],
41
+ };
42
+ store.accounts.set(input.email, account);
43
+ if (store.requireVerifiedEmail) {
44
+ return Promise.resolve({
45
+ email: input.email,
46
+ roles: effectiveRoles,
47
+ requiresVerification: true,
48
+ });
49
+ }
50
+ const jwt = mintJwt(input.email);
51
+ store.jwtsByEmail.set(input.email, jwt);
52
+ return Promise.resolve({
53
+ jwt,
54
+ email: input.email,
55
+ roles: effectiveRoles,
56
+ isVerified: account.isVerified,
57
+ });
58
+ },
59
+ login(input, _ctx) {
60
+ const acc = store.accounts.get(input.email);
61
+ if (!acc || acc.password !== input.password) {
62
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
63
+ }
64
+ if (store.requireVerifiedEmail && !acc.isVerified) {
65
+ return Promise.reject(Object.assign(new Error("EMAIL_NOT_VERIFIED"), {
66
+ status: 403,
67
+ code: "EMAIL_NOT_VERIFIED",
68
+ }));
69
+ }
70
+ const jwt = mintJwt(input.email);
71
+ store.jwtsByEmail.set(input.email, jwt);
72
+ return Promise.resolve({
73
+ jwt,
74
+ email: acc.email,
75
+ roles: acc.roles,
76
+ isVerified: acc.isVerified,
77
+ });
78
+ },
79
+ logout(ctx) {
80
+ // Revoke the JWT associated with this bearer.
81
+ const jwt = ctx.jwt;
82
+ if (jwt) {
83
+ for (const [email, j] of store.jwtsByEmail) {
84
+ if (j === jwt) {
85
+ store.jwtsByEmail.delete(email);
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ return Promise.resolve();
91
+ },
92
+ oauthInitUrl(provider, opts) {
93
+ const qs = new URLSearchParams({ action: opts.action });
94
+ if (opts.redirect)
95
+ qs.set("redirect", opts.redirect);
96
+ if (opts.lang)
97
+ qs.set("lang", opts.lang);
98
+ return `mock://oauth/${provider}/init?${qs}`;
99
+ },
100
+ resendVerification(input, _ctx) {
101
+ // Silently succeed whether or not the account exists.
102
+ if (!store.accounts.has(input.email))
103
+ return Promise.resolve();
104
+ // No-op in the mock — verifyMockAccount() stands in for the user
105
+ // clicking the link.
106
+ return Promise.resolve();
107
+ },
108
+ requestPasswordReset(_input, _ctx) {
109
+ return Promise.resolve();
110
+ },
111
+ changePassword(input, ctx) {
112
+ const jwt = ctx.jwt;
113
+ let email;
114
+ if (jwt) {
115
+ for (const [e, j] of store.jwtsByEmail) {
116
+ if (j === jwt) {
117
+ email = e;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ if (!email) {
123
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
124
+ }
125
+ const acc = store.accounts.get(email);
126
+ if (!acc) {
127
+ return Promise.reject(Object.assign(new Error("NotFound"), { status: 404 }));
128
+ }
129
+ if (acc.password !== input.current_password) {
130
+ return Promise.reject(Object.assign(new Error("BadRequest"), { status: 400 }));
131
+ }
132
+ acc.password = input.new_password;
133
+ return Promise.resolve();
134
+ },
135
+ deleteAccount(_input, ctx) {
136
+ const jwt = ctx.jwt;
137
+ if (!jwt) {
138
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
139
+ }
140
+ for (const [email, j] of store.jwtsByEmail) {
141
+ if (j === jwt) {
142
+ store.accounts.delete(email);
143
+ store.jwtsByEmail.delete(email);
144
+ return Promise.resolve({ deleted: true });
145
+ }
146
+ }
147
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
148
+ },
149
+ };
150
+ }
151
+ export function createMockProfileAdapter(store) {
152
+ function emailFromCtx(ctx) {
153
+ const jwt = ctx.jwt;
154
+ if (!jwt)
155
+ return null;
156
+ for (const [email, j] of store.jwtsByEmail) {
157
+ if (j === jwt)
158
+ return email;
159
+ }
160
+ return null;
161
+ }
162
+ return {
163
+ get(ctx) {
164
+ const email = emailFromCtx(ctx);
165
+ if (!email) {
166
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
167
+ }
168
+ const acc = store.accounts.get(email);
169
+ if (!acc) {
170
+ return Promise.reject(Object.assign(new Error("NotFound"), { status: 404 }));
171
+ }
172
+ return Promise.resolve({
173
+ email: acc.email,
174
+ roles: acc.roles,
175
+ isVerified: acc.isVerified,
176
+ hasPassword: acc.hasPassword,
177
+ oauthConnections: [...acc.oauthConnections],
178
+ });
179
+ },
180
+ update(input, ctx) {
181
+ const email = emailFromCtx(ctx);
182
+ if (!email) {
183
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
184
+ }
185
+ const acc = store.accounts.get(email);
186
+ if (!acc) {
187
+ return Promise.reject(Object.assign(new Error("NotFound"), { status: 404 }));
188
+ }
189
+ if (input.email && input.email !== acc.email) {
190
+ if (store.requireVerifiedEmail)
191
+ acc.isVerified = false;
192
+ store.accounts.delete(acc.email);
193
+ acc.email = input.email;
194
+ store.accounts.set(acc.email, acc);
195
+ // Refresh JWT mapping too.
196
+ const jwt = store.jwtsByEmail.get(email);
197
+ if (jwt) {
198
+ store.jwtsByEmail.delete(email);
199
+ store.jwtsByEmail.set(acc.email, jwt);
200
+ }
201
+ }
202
+ return Promise.resolve({
203
+ email: acc.email,
204
+ roles: acc.roles,
205
+ isVerified: acc.isVerified,
206
+ hasPassword: acc.hasPassword,
207
+ oauthConnections: [...acc.oauthConnections],
208
+ });
209
+ },
210
+ listOAuth(ctx) {
211
+ const email = emailFromCtx(ctx);
212
+ if (!email)
213
+ return Promise.resolve([]);
214
+ const acc = store.accounts.get(email);
215
+ return Promise.resolve(acc ? [...acc.oauthConnections] : []);
216
+ },
217
+ unlinkOAuth(provider, ctx) {
218
+ const email = emailFromCtx(ctx);
219
+ if (!email) {
220
+ return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 }));
221
+ }
222
+ const acc = store.accounts.get(email);
223
+ if (!acc) {
224
+ return Promise.reject(Object.assign(new Error("NotFound"), { status: 404 }));
225
+ }
226
+ acc.oauthConnections = acc.oauthConnections.filter((c) => c.provider !== provider);
227
+ return Promise.resolve();
228
+ },
229
+ };
230
+ }
231
+ /** Test helper — mark a mock account as verified as if the user had clicked
232
+ * the link in the verification email. */
233
+ export function verifyMockAccount(store, email) {
234
+ const acc = store.accounts.get(email);
235
+ if (acc)
236
+ acc.isVerified = true;
237
+ }
@@ -1 +1,3 @@
1
1
  export * from "./mock.js";
2
+ export * from "./mock-auth.js";
3
+ export * from "./stack-account.js";
@@ -1 +1,3 @@
1
1
  export * from "./mock.js";
2
+ export * from "./mock-auth.js";
3
+ export * from "./stack-account.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @module adapters/stack-account
3
+ *
4
+ * Default implementations of `AuthAdapter` and `ProfileAdapter` that target
5
+ * the `@marianmeres/stack-account` REST surface:
6
+ *
7
+ * POST /api/account/register
8
+ * POST /api/account/login
9
+ * POST /api/account/logout
10
+ * GET /api/account/oauth/[provider]/init
11
+ * POST /api/account/verify/resend
12
+ * POST /api/account/password/reset
13
+ * POST /api/account/password/change
14
+ * GET/PUT/DELETE /api/account/me
15
+ * GET /api/account/me/oauth
16
+ * DELETE /api/account/me/oauth/[provider]
17
+ *
18
+ * Apps whose server mounts stack-account at this path can just do:
19
+ *
20
+ * const suite = createOwnsuite({
21
+ * adapters: {
22
+ * auth: createStackAccountAuthAdapter({ baseUrl: "/api/account" }),
23
+ * profile: createStackAccountProfileAdapter({ baseUrl: "/api/account" }),
24
+ * },
25
+ * });
26
+ *
27
+ * Apps with custom routes should write their own adapter against the
28
+ * AuthAdapter / ProfileAdapter interfaces.
29
+ */
30
+ import type { AuthAdapter, ProfileAdapter } from "../types/mod.js";
31
+ export interface StackAccountAdapterOptions {
32
+ /** Base URL of the mounted stack-account app. Default: "/api/account". */
33
+ baseUrl?: string;
34
+ /** Override the `fetch` implementation (useful for tests / SSR). */
35
+ fetch?: typeof fetch;
36
+ }
37
+ export declare function createStackAccountAuthAdapter(opts?: StackAccountAdapterOptions): AuthAdapter;
38
+ export declare function createStackAccountProfileAdapter(opts?: StackAccountAdapterOptions): ProfileAdapter;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @module adapters/stack-account
3
+ *
4
+ * Default implementations of `AuthAdapter` and `ProfileAdapter` that target
5
+ * the `@marianmeres/stack-account` REST surface:
6
+ *
7
+ * POST /api/account/register
8
+ * POST /api/account/login
9
+ * POST /api/account/logout
10
+ * GET /api/account/oauth/[provider]/init
11
+ * POST /api/account/verify/resend
12
+ * POST /api/account/password/reset
13
+ * POST /api/account/password/change
14
+ * GET/PUT/DELETE /api/account/me
15
+ * GET /api/account/me/oauth
16
+ * DELETE /api/account/me/oauth/[provider]
17
+ *
18
+ * Apps whose server mounts stack-account at this path can just do:
19
+ *
20
+ * const suite = createOwnsuite({
21
+ * adapters: {
22
+ * auth: createStackAccountAuthAdapter({ baseUrl: "/api/account" }),
23
+ * profile: createStackAccountProfileAdapter({ baseUrl: "/api/account" }),
24
+ * },
25
+ * });
26
+ *
27
+ * Apps with custom routes should write their own adapter against the
28
+ * AuthAdapter / ProfileAdapter interfaces.
29
+ */
30
+ function resolveFetch(opts) {
31
+ return opts?.fetch ?? (globalThis.fetch.bind(globalThis));
32
+ }
33
+ function join(base, path) {
34
+ if (!base)
35
+ return path;
36
+ if (base.endsWith("/"))
37
+ return `${base.slice(0, -1)}${path}`;
38
+ return `${base}${path}`;
39
+ }
40
+ function authHeaders(ctx) {
41
+ return ctx.jwt ? { Authorization: `Bearer ${ctx.jwt}` } : {};
42
+ }
43
+ async function postJson(doFetch, url, body, ctx) {
44
+ const res = await doFetch(url, {
45
+ method: "POST",
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ ...authHeaders(ctx),
49
+ },
50
+ body: JSON.stringify(body),
51
+ signal: ctx.signal,
52
+ });
53
+ if (!res.ok) {
54
+ const text = await res.text();
55
+ throw Object.assign(new Error(text || res.statusText), {
56
+ status: res.status,
57
+ body: text,
58
+ });
59
+ }
60
+ return (await res.json());
61
+ }
62
+ async function requestJson(doFetch, url, init, ctx) {
63
+ const res = await doFetch(url, {
64
+ ...init,
65
+ headers: {
66
+ ...(init.headers ?? {}),
67
+ ...authHeaders(ctx),
68
+ ...(init.body
69
+ ? { "Content-Type": "application/json" }
70
+ : {}),
71
+ },
72
+ signal: ctx.signal,
73
+ });
74
+ if (!res.ok) {
75
+ const text = await res.text();
76
+ throw Object.assign(new Error(text || res.statusText), {
77
+ status: res.status,
78
+ body: text,
79
+ });
80
+ }
81
+ if (res.status === 204)
82
+ return undefined;
83
+ return (await res.json());
84
+ }
85
+ export function createStackAccountAuthAdapter(opts = {}) {
86
+ const base = opts.baseUrl ?? "/api/account";
87
+ const doFetch = resolveFetch(opts);
88
+ return {
89
+ async register(input, ctx) {
90
+ const r = await postJson(doFetch, join(base, "/register"), input, ctx);
91
+ return r.data;
92
+ },
93
+ async login(input, ctx) {
94
+ const r = await postJson(doFetch, join(base, "/login"), input, ctx);
95
+ return r.data;
96
+ },
97
+ async logout(ctx) {
98
+ await requestJson(doFetch, join(base, "/logout"), { method: "POST" }, ctx);
99
+ },
100
+ oauthInitUrl(provider, options, _ctx) {
101
+ const qs = new URLSearchParams({ action: options.action });
102
+ if (options.redirect)
103
+ qs.set("redirect", options.redirect);
104
+ if (options.lang)
105
+ qs.set("lang", options.lang);
106
+ return `${join(base, `/oauth/${provider}/init`)}?${qs}`;
107
+ },
108
+ async resendVerification(input, ctx) {
109
+ await postJson(doFetch, join(base, "/verify/resend"), input, ctx);
110
+ },
111
+ async requestPasswordReset(input, ctx) {
112
+ await postJson(doFetch, join(base, "/password/reset"), input, ctx);
113
+ },
114
+ async changePassword(input, ctx) {
115
+ await postJson(doFetch, join(base, "/password/change"), input, ctx);
116
+ },
117
+ async deleteAccount(input, ctx) {
118
+ await requestJson(doFetch, join(base, "/me"), {
119
+ method: "DELETE",
120
+ body: JSON.stringify(input),
121
+ }, ctx);
122
+ return { deleted: true };
123
+ },
124
+ };
125
+ }
126
+ export function createStackAccountProfileAdapter(opts = {}) {
127
+ const base = opts.baseUrl ?? "/api/account";
128
+ const doFetch = resolveFetch(opts);
129
+ return {
130
+ async get(ctx) {
131
+ const r = await requestJson(doFetch, join(base, "/me"), { method: "GET" }, ctx);
132
+ return r.data;
133
+ },
134
+ async update(input, ctx) {
135
+ const r = await requestJson(doFetch, join(base, "/me"), {
136
+ method: "PUT",
137
+ body: JSON.stringify(input),
138
+ }, ctx);
139
+ return r.data;
140
+ },
141
+ async listOAuth(ctx) {
142
+ const r = await requestJson(doFetch, join(base, "/me/oauth"), { method: "GET" }, ctx);
143
+ return r.data ?? [];
144
+ },
145
+ async unlinkOAuth(provider, ctx) {
146
+ await requestJson(doFetch, join(base, `/me/oauth/${provider}`), { method: "DELETE" }, ctx);
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @module domains/auth
3
+ *
4
+ * AuthManager — verbs for the account lifecycle. Holds no state of its own:
5
+ * - credentials flow through to the adapter,
6
+ * - server responses are piped into the SessionManager,
7
+ * - successful session changes trigger a refresh of owner-scoped domains
8
+ * via a caller-supplied `switchIdentity` hook.
9
+ *
10
+ * Non-goals (explicit): password strength UI, form validation, rendering,
11
+ * i18n strings, CSRF/PKCE (server-side), refresh-token rotation, cookie
12
+ * management.
13
+ */
14
+ import { type PubSub } from "@marianmeres/pubsub";
15
+ import type { AuthActionOptions, AuthAdapter, AuthTokenResult, OAuthInitOptions, OAuthProvider, OwnsuiteContext, ProfileAdapter } from "../types/mod.js";
16
+ import type { SessionManager } from "./session.js";
17
+ import type { ProfileManager } from "./profile.js";
18
+ export interface AuthManagerOptions {
19
+ adapter: AuthAdapter;
20
+ session: SessionManager;
21
+ /** Profile manager — auth hydrates the session subject from `/me`
22
+ * immediately after login so subscribers see roles/isVerified/etc.
23
+ * without a second await. */
24
+ profile: ProfileManager;
25
+ pubsub?: PubSub;
26
+ /** Called after a successful identity change (register/login/OAuth
27
+ * login/logout). The orchestrator wires this to reset + re-init every
28
+ * owner-scoped domain with the fresh context. */
29
+ onIdentityChanged?: (ctx: OwnsuiteContext) => Promise<void> | void;
30
+ /** Also passed into adapter calls (correlation id, feature flags, etc.).
31
+ * The JWT + signal are added per-op by the manager. */
32
+ context?: OwnsuiteContext;
33
+ /** When the adapter provides a profile adapter explicitly, we pass it
34
+ * through to resolving the subject. Kept optional for mock-driven
35
+ * tests that don't need a separate profile fetch. */
36
+ profileAdapter?: ProfileAdapter;
37
+ }
38
+ export declare class AuthManager {
39
+ #private;
40
+ constructor(options: AuthManagerOptions);
41
+ setContext(ctx: OwnsuiteContext): void;
42
+ replaceContext(ctx: OwnsuiteContext): void;
43
+ register(input: {
44
+ email: string;
45
+ password: string;
46
+ password_confirm: string;
47
+ roles?: string[];
48
+ extras?: Record<string, unknown>;
49
+ }, options?: AuthActionOptions): Promise<AuthTokenResult>;
50
+ login(input: {
51
+ email: string;
52
+ password: string;
53
+ }, options?: AuthActionOptions): Promise<AuthTokenResult>;
54
+ logout(): Promise<void>;
55
+ resendVerification(input: {
56
+ email: string;
57
+ lang?: string;
58
+ }): Promise<void>;
59
+ requestPasswordReset(input: {
60
+ email: string;
61
+ lang?: string;
62
+ }): Promise<void>;
63
+ changePassword(input: {
64
+ current_password?: string;
65
+ new_password: string;
66
+ confirm_password: string;
67
+ token?: string;
68
+ }): Promise<void>;
69
+ deleteAccount(input: {
70
+ password?: string;
71
+ confirm?: boolean;
72
+ }): Promise<void>;
73
+ /**
74
+ * Begin an OAuth flow. Returns:
75
+ * - For popup mode: a Promise that resolves when the popup posts back
76
+ * an auth result.
77
+ * - For redirect mode: nothing useful; the top window navigates away.
78
+ */
79
+ initiateOAuth(provider: OAuthProvider, opts: OAuthInitOptions): Promise<AuthTokenResult | void>;
80
+ /** For the redirect-mode callback page: delegate to the adapter if
81
+ * available to extract the result from the current URL/page state.
82
+ * `options.remember` pins the resulting session to local/session
83
+ * storage — pass the same value the user picked before the redirect. */
84
+ handleOAuthCallback(options?: AuthActionOptions): Promise<AuthTokenResult | void>;
85
+ }