@marianmeres/ownsuite 2.0.0 → 2.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.
@@ -0,0 +1,211 @@
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 { createPubSub } from "@marianmeres/pubsub";
15
+ import { openOAuthPopup } from "../oauth/popup.js";
16
+ export class AuthManager {
17
+ #pubsub;
18
+ #adapter;
19
+ #session;
20
+ #profile;
21
+ #onIdentityChanged;
22
+ #context;
23
+ constructor(options) {
24
+ this.#adapter = options.adapter;
25
+ this.#session = options.session;
26
+ this.#profile = options.profile;
27
+ this.#pubsub = options.pubsub ?? createPubSub();
28
+ this.#onIdentityChanged = options.onIdentityChanged;
29
+ this.#context = options.context ?? {};
30
+ }
31
+ setContext(ctx) {
32
+ this.#context = { ...this.#context, ...ctx };
33
+ }
34
+ replaceContext(ctx) {
35
+ this.#context = { ...ctx };
36
+ }
37
+ #ctx(signal) {
38
+ const jwt = this.#session.getJwt();
39
+ return {
40
+ ...this.#context,
41
+ ...(jwt ? { jwt } : {}),
42
+ ...(signal ? { signal } : {}),
43
+ };
44
+ }
45
+ /** Flip the session state based on an AuthTokenResult. When the JWT is
46
+ * present, we pull the profile to build a complete subject. When the
47
+ * server returned requiresVerification, we surface the "unverified"
48
+ * status without any profile fetch. Triggers identity-change hook on
49
+ * actual login. */
50
+ async #applyAuthResult(result) {
51
+ if (result.requiresVerification || !result.jwt) {
52
+ this.#session.setUnverified(result.email);
53
+ this.#pubsub.publish("auth:verification:required", {
54
+ type: "auth:verification:required",
55
+ timestamp: Date.now(),
56
+ email: result.email,
57
+ });
58
+ return result;
59
+ }
60
+ // Set a provisional subject so subsequent calls (including the profile
61
+ // fetch itself) see the JWT on the session.
62
+ const provisional = {
63
+ id: "",
64
+ email: result.email,
65
+ roles: result.roles ?? [],
66
+ isVerified: result.isVerified ?? false,
67
+ hasPassword: true, // corrected by profile fetch below
68
+ };
69
+ this.#session.setAuthenticated({
70
+ jwt: result.jwt,
71
+ subject: provisional,
72
+ expiresAt: result.validUntil ?? null,
73
+ });
74
+ // Best-effort hydrate the subject from /me. If this fails we keep the
75
+ // provisional subject; consumers can retry via profile.fetch().
76
+ try {
77
+ await this.#profile.fetch();
78
+ }
79
+ catch {
80
+ // swallow — session stays authenticated with the provisional subject
81
+ }
82
+ if (this.#onIdentityChanged) {
83
+ try {
84
+ await this.#onIdentityChanged(this.#ctx());
85
+ }
86
+ catch {
87
+ // swallow — hook errors must not destabilize auth state
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+ // ─────────────────────── verbs ──────────────────────────────────────────
93
+ async register(input) {
94
+ const result = await this.#adapter.register(input, this.#ctx());
95
+ this.#pubsub.publish("auth:register", {
96
+ type: "auth:register",
97
+ timestamp: Date.now(),
98
+ email: input.email,
99
+ requiresVerification: Boolean(result.requiresVerification),
100
+ });
101
+ return await this.#applyAuthResult(result);
102
+ }
103
+ async login(input) {
104
+ const result = await this.#adapter.login(input, this.#ctx());
105
+ this.#pubsub.publish("auth:login", {
106
+ type: "auth:login",
107
+ timestamp: Date.now(),
108
+ email: input.email,
109
+ });
110
+ return await this.#applyAuthResult(result);
111
+ }
112
+ async logout() {
113
+ const subjectId = this.#session.get().subject?.id;
114
+ try {
115
+ await this.#adapter.logout(this.#ctx());
116
+ }
117
+ catch {
118
+ // server logout is best-effort — still clear locally
119
+ }
120
+ this.#session.clear();
121
+ this.#pubsub.publish("auth:logout", {
122
+ type: "auth:logout",
123
+ timestamp: Date.now(),
124
+ subjectId,
125
+ });
126
+ if (this.#onIdentityChanged) {
127
+ try {
128
+ await this.#onIdentityChanged(this.#ctx());
129
+ }
130
+ catch {
131
+ // swallow
132
+ }
133
+ }
134
+ }
135
+ async resendVerification(input) {
136
+ await this.#adapter.resendVerification(input, this.#ctx());
137
+ }
138
+ async requestPasswordReset(input) {
139
+ await this.#adapter.requestPasswordReset(input, this.#ctx());
140
+ }
141
+ async changePassword(input) {
142
+ await this.#adapter.changePassword(input, this.#ctx());
143
+ }
144
+ async deleteAccount(input) {
145
+ await this.#adapter.deleteAccount(input, this.#ctx());
146
+ // Clear session locally and fire logout/identity-change.
147
+ this.#session.clear();
148
+ this.#pubsub.publish("auth:logout", {
149
+ type: "auth:logout",
150
+ timestamp: Date.now(),
151
+ });
152
+ if (this.#onIdentityChanged) {
153
+ try {
154
+ await this.#onIdentityChanged(this.#ctx());
155
+ }
156
+ catch {
157
+ // swallow
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * Begin an OAuth flow. Returns:
163
+ * - For popup mode: a Promise that resolves when the popup posts back
164
+ * an auth result.
165
+ * - For redirect mode: nothing useful; the top window navigates away.
166
+ */
167
+ async initiateOAuth(provider, opts) {
168
+ const url = this.#adapter.oauthInitUrl(provider, opts, this.#ctx());
169
+ const mode = opts.mode ?? "popup";
170
+ if (mode === "redirect") {
171
+ if (typeof globalThis !== "undefined" && "location" in globalThis) {
172
+ globalThis.location.href = url;
173
+ }
174
+ return;
175
+ }
176
+ // popup mode — wait for postMessage.
177
+ const message = await openOAuthPopup(url);
178
+ if (message.type === "oauth_link_success") {
179
+ this.#pubsub.publish("oauth:linked", {
180
+ type: "oauth:linked",
181
+ timestamp: Date.now(),
182
+ connection: { provider },
183
+ });
184
+ // Refresh profile so the new connection appears.
185
+ try {
186
+ await this.#profile.fetch();
187
+ }
188
+ catch {
189
+ // swallow
190
+ }
191
+ return;
192
+ }
193
+ // login result
194
+ const result = {
195
+ jwt: message.jwt,
196
+ email: message.email,
197
+ roles: message.roles ?? [],
198
+ isVerified: true,
199
+ };
200
+ return await this.#applyAuthResult(result);
201
+ }
202
+ /** For the redirect-mode callback page: delegate to the adapter if
203
+ * available to extract the result from the current URL/page state. */
204
+ async handleOAuthCallback() {
205
+ if (!this.#adapter.handleOAuthCallback) {
206
+ throw new Error("AuthManager: handleOAuthCallback is not implemented by the adapter");
207
+ }
208
+ const result = await this.#adapter.handleOAuthCallback(this.#ctx());
209
+ return await this.#applyAuthResult(result);
210
+ }
211
+ }
@@ -1,2 +1,5 @@
1
1
  export * from "./base.js";
2
2
  export * from "./owned-collection.js";
3
+ export * from "./session.js";
4
+ export * from "./profile.js";
5
+ export * from "./auth.js";
@@ -1,2 +1,5 @@
1
1
  export * from "./base.js";
2
2
  export * from "./owned-collection.js";
3
+ export * from "./session.js";
4
+ export * from "./profile.js";
5
+ export * from "./auth.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @module domains/profile
3
+ *
4
+ * ProfileManager — a singleton (one-row) reactive container for the
5
+ * authenticated subject's `/me` data: email, roles, verification flag,
6
+ * whether the account has a password, and the list of linked OAuth
7
+ * connections.
8
+ *
9
+ * Deliberately NOT an OwnedCollectionManager. The underlying `/me` endpoint
10
+ * is a single-record resource: no list, no optimistic create/delete, and
11
+ * update returns the full profile. Shoehorning it into the collection
12
+ * manager would leak CRUD semantics that don't apply and complicate
13
+ * ownership semantics.
14
+ *
15
+ * This manager mutates the companion `SessionManager`'s subject in place
16
+ * when the profile changes (e.g. email edited) so consumers reading from
17
+ * `suite.session` see the update without a second fetch.
18
+ */
19
+ import { type StoreLike } from "@marianmeres/store";
20
+ import { type PubSub } from "@marianmeres/pubsub";
21
+ import type { OAuthConnection, OAuthProvider, OwnsuiteContext, ProfileAdapter, ProfileResult } from "../types/mod.js";
22
+ import type { SessionManager } from "./session.js";
23
+ export interface ProfileManagerOptions {
24
+ adapter: ProfileAdapter;
25
+ session: SessionManager;
26
+ /** Shared pubsub for event emission. Private if omitted. */
27
+ pubsub?: PubSub;
28
+ /** Initial context passed to adapter calls. Extended per-call with
29
+ * `jwt` and `signal` by the manager. */
30
+ context?: OwnsuiteContext;
31
+ }
32
+ export interface ProfileState {
33
+ /** Null until the first successful fetch. */
34
+ profile: ProfileResult | null;
35
+ /** True while a request is in flight. */
36
+ loading: boolean;
37
+ /** Most recent error, or null. Cleared on the next successful call. */
38
+ error: Error | null;
39
+ }
40
+ /**
41
+ * Profile manager — singleton state for `/me`.
42
+ */
43
+ export declare class ProfileManager {
44
+ #private;
45
+ constructor(options: ProfileManagerOptions);
46
+ get subscribe(): StoreLike<ProfileState>["subscribe"];
47
+ get(): ProfileState;
48
+ setContext(ctx: OwnsuiteContext): void;
49
+ replaceContext(ctx: OwnsuiteContext): void;
50
+ /** Fetch `/me`. Supersedes any in-flight fetch. */
51
+ fetch(): Promise<ProfileResult>;
52
+ /** Update profile (currently: email). Triggers a re-verification email
53
+ * server-side when the gate is on. Returns the refreshed profile. */
54
+ update(input: {
55
+ email?: string;
56
+ current_password?: string;
57
+ }): Promise<ProfileResult>;
58
+ listOAuth(): Promise<OAuthConnection[]>;
59
+ unlinkOAuth(provider: OAuthProvider): Promise<void>;
60
+ reset(): void;
61
+ destroy(): void;
62
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @module domains/profile
3
+ *
4
+ * ProfileManager — a singleton (one-row) reactive container for the
5
+ * authenticated subject's `/me` data: email, roles, verification flag,
6
+ * whether the account has a password, and the list of linked OAuth
7
+ * connections.
8
+ *
9
+ * Deliberately NOT an OwnedCollectionManager. The underlying `/me` endpoint
10
+ * is a single-record resource: no list, no optimistic create/delete, and
11
+ * update returns the full profile. Shoehorning it into the collection
12
+ * manager would leak CRUD semantics that don't apply and complicate
13
+ * ownership semantics.
14
+ *
15
+ * This manager mutates the companion `SessionManager`'s subject in place
16
+ * when the profile changes (e.g. email edited) so consumers reading from
17
+ * `suite.session` see the update without a second fetch.
18
+ */
19
+ import { createStore } from "@marianmeres/store";
20
+ import { createPubSub } from "@marianmeres/pubsub";
21
+ const EMPTY = {
22
+ profile: null,
23
+ loading: false,
24
+ error: null,
25
+ };
26
+ /**
27
+ * Profile manager — singleton state for `/me`.
28
+ */
29
+ export class ProfileManager {
30
+ #store;
31
+ #pubsub;
32
+ #adapter;
33
+ #session;
34
+ #context;
35
+ /** Currently-active read controller, for abort-supersede semantics. */
36
+ #readController = null;
37
+ constructor(options) {
38
+ this.#adapter = options.adapter;
39
+ this.#session = options.session;
40
+ this.#pubsub = options.pubsub ?? createPubSub();
41
+ this.#context = options.context ?? {};
42
+ this.#store = createStore({ ...EMPTY });
43
+ }
44
+ get subscribe() {
45
+ return this.#store.subscribe;
46
+ }
47
+ get() {
48
+ return this.#store.get();
49
+ }
50
+ setContext(ctx) {
51
+ this.#context = { ...this.#context, ...ctx };
52
+ }
53
+ replaceContext(ctx) {
54
+ this.#context = { ...ctx };
55
+ }
56
+ /** Build a per-op context with the current JWT from the session. */
57
+ #ctxFor(signal) {
58
+ const jwt = this.#session.getJwt();
59
+ return {
60
+ ...this.#context,
61
+ ...(jwt ? { jwt } : {}),
62
+ signal,
63
+ };
64
+ }
65
+ #abortActiveRead(reason = "superseded") {
66
+ if (this.#readController) {
67
+ try {
68
+ this.#readController.abort(reason);
69
+ }
70
+ catch {
71
+ // ignore
72
+ }
73
+ this.#readController = null;
74
+ }
75
+ }
76
+ /** Fetch `/me`. Supersedes any in-flight fetch. */
77
+ async fetch() {
78
+ this.#abortActiveRead();
79
+ const ctrl = new AbortController();
80
+ this.#readController = ctrl;
81
+ this.#store.update((s) => ({ ...s, loading: true }));
82
+ try {
83
+ const profile = await this.#adapter.get(this.#ctxFor(ctrl.signal));
84
+ if (ctrl.signal.aborted) {
85
+ // A newer request already took over; don't overwrite its data.
86
+ throw new Error("aborted");
87
+ }
88
+ this.#store.set({
89
+ profile,
90
+ loading: false,
91
+ error: null,
92
+ });
93
+ // Keep the session's subject in sync with /me so consumers reading
94
+ // from session don't need to also subscribe to profile.
95
+ this.#session.patchSubject({
96
+ email: profile.email,
97
+ roles: profile.roles,
98
+ isVerified: profile.isVerified,
99
+ hasPassword: profile.hasPassword,
100
+ });
101
+ return profile;
102
+ }
103
+ catch (e) {
104
+ if (!ctrl.signal.aborted) {
105
+ const err = e instanceof Error ? e : new Error(String(e));
106
+ this.#store.update((s) => ({ ...s, loading: false, error: err }));
107
+ }
108
+ throw e;
109
+ }
110
+ finally {
111
+ if (this.#readController === ctrl)
112
+ this.#readController = null;
113
+ }
114
+ }
115
+ /** Update profile (currently: email). Triggers a re-verification email
116
+ * server-side when the gate is on. Returns the refreshed profile. */
117
+ async update(input) {
118
+ const ctrl = new AbortController();
119
+ this.#store.update((s) => ({ ...s, loading: true }));
120
+ try {
121
+ const profile = await this.#adapter.update(input, this.#ctxFor(ctrl.signal));
122
+ this.#store.set({ profile, loading: false, error: null });
123
+ this.#session.patchSubject({
124
+ email: profile.email,
125
+ roles: profile.roles,
126
+ isVerified: profile.isVerified,
127
+ hasPassword: profile.hasPassword,
128
+ });
129
+ this.#pubsub.publish("profile:updated", {
130
+ type: "profile:updated",
131
+ timestamp: Date.now(),
132
+ email: profile.email,
133
+ });
134
+ return profile;
135
+ }
136
+ catch (e) {
137
+ const err = e instanceof Error ? e : new Error(String(e));
138
+ this.#store.update((s) => ({ ...s, loading: false, error: err }));
139
+ throw e;
140
+ }
141
+ }
142
+ async listOAuth() {
143
+ const ctrl = new AbortController();
144
+ return await this.#adapter.listOAuth(this.#ctxFor(ctrl.signal));
145
+ }
146
+ async unlinkOAuth(provider) {
147
+ const ctrl = new AbortController();
148
+ await this.#adapter.unlinkOAuth(provider, this.#ctxFor(ctrl.signal));
149
+ this.#pubsub.publish("oauth:unlinked", {
150
+ type: "oauth:unlinked",
151
+ timestamp: Date.now(),
152
+ provider,
153
+ });
154
+ // Refresh so the profile reflects the new connection list.
155
+ try {
156
+ await this.fetch();
157
+ }
158
+ catch {
159
+ // swallow — caller already got a successful unlink
160
+ }
161
+ }
162
+ reset() {
163
+ this.#abortActiveRead("reset");
164
+ this.#store.set({ ...EMPTY });
165
+ }
166
+ destroy() {
167
+ this.#abortActiveRead("destroyed");
168
+ this.#store.set({ ...EMPTY });
169
+ }
170
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module domains/session
3
+ *
4
+ * SessionManager — source of truth for the JWT and the authenticated subject.
5
+ *
6
+ * This is NOT an `OwnedCollectionManager` — there's no list-of-rows and no
7
+ * remote adapter. The session is a single reactive record, persisted via a
8
+ * pluggable `SessionStorage`, that drives every other manager (its JWT and
9
+ * subjectId end up on `OwnsuiteContext`).
10
+ *
11
+ * Consumers subscribe directly (`suite.session.subscribe(fn)`) or read the
12
+ * current snapshot (`suite.session.get()`). Writes go through the high-
13
+ * level `AuthManager` methods (`login`, `logout`, etc.) which call into
14
+ * this manager.
15
+ */
16
+ import { type StoreLike } from "@marianmeres/store";
17
+ import { type PubSub } from "@marianmeres/pubsub";
18
+ import type { SessionState, SessionStorage, SessionStorageType, SessionSubject } from "../types/auth.js";
19
+ /** In-memory session storage — survives the current JS realm only. */
20
+ export declare function createMemorySessionStorage(): SessionStorage;
21
+ /** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
22
+ * Gracefully falls back to memory storage when running in an environment
23
+ * without Web Storage (SSR, worker without storage, etc). */
24
+ export declare function resolveSessionStorage(type?: SessionStorageType): SessionStorage;
25
+ export interface SessionManagerOptions {
26
+ /** Storage backend for session persistence. Default: "local". */
27
+ storage?: SessionStorageType;
28
+ /** Key used in the storage backend. Default: "ownsuite:session". */
29
+ storageKey?: string;
30
+ /** Shared pubsub for event emission. When omitted, a private one is
31
+ * created — useful for standalone testing of the manager. */
32
+ pubsub?: PubSub;
33
+ }
34
+ /**
35
+ * Session manager — pure reactive state + persistence, no HTTP.
36
+ *
37
+ * Writes are driven by the AuthManager. On construction it hydrates from
38
+ * storage; if the stored JWT has expired, it transitions to anonymous and
39
+ * clears the storage.
40
+ */
41
+ export declare class SessionManager {
42
+ #private;
43
+ constructor(options?: SessionManagerOptions);
44
+ /** Svelte-compatible subscribe. */
45
+ get subscribe(): StoreLike<SessionState>["subscribe"];
46
+ /** Current session state snapshot. */
47
+ get(): SessionState;
48
+ get isAuthenticated(): boolean;
49
+ get isUnverified(): boolean;
50
+ get isAnonymous(): boolean;
51
+ /** JWT for adapter `Authorization` headers, or null when anonymous. */
52
+ getJwt(): string | null;
53
+ /** Transition to authenticated. Called after login/register/OAuth succeed
54
+ * and the subject has been loaded. */
55
+ setAuthenticated(opts: {
56
+ jwt: string;
57
+ subject: SessionSubject;
58
+ expiresAt?: number | null;
59
+ }): void;
60
+ /** Server confirmed credentials but refuses login until email is
61
+ * verified. Expose the email so the UI can prompt "check your inbox"
62
+ * without a second server call. No JWT in this state. */
63
+ setUnverified(email: string): void;
64
+ /** Drop the session. Storage is cleared; downstream domains should be
65
+ * reset by the suite orchestrator. */
66
+ clear(): void;
67
+ /** Patch the subject in place without touching the JWT. Used when the
68
+ * profile manager changes email / linked providers / etc. */
69
+ patchSubject(patch: Partial<SessionSubject>): void;
70
+ /** Test / reset hook — clears storage AND in-memory state without
71
+ * emitting. Used by teardown. */
72
+ destroy(): void;
73
+ }