@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,224 @@
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
+ *
51
+ * `remember` translates to a per-login storage pin on the session:
52
+ * `true` → `"local"`, `false` → `"session"`, `undefined` → session
53
+ * manager's configured default. Silently no-op in the verification-gate
54
+ * branch (no JWT yet → nothing to persist). */
55
+ async #applyAuthResult(result, remember) {
56
+ if (result.requiresVerification || !result.jwt) {
57
+ this.#session.setUnverified(result.email);
58
+ this.#pubsub.publish("auth:verification:required", {
59
+ type: "auth:verification:required",
60
+ timestamp: Date.now(),
61
+ email: result.email,
62
+ });
63
+ return result;
64
+ }
65
+ // Set a provisional subject so subsequent calls (including the profile
66
+ // fetch itself) see the JWT on the session.
67
+ const provisional = {
68
+ id: "",
69
+ email: result.email,
70
+ roles: result.roles ?? [],
71
+ isVerified: result.isVerified ?? false,
72
+ hasPassword: true, // corrected by profile fetch below
73
+ };
74
+ const storage = remember === true
75
+ ? "local"
76
+ : remember === false
77
+ ? "session"
78
+ : undefined;
79
+ this.#session.setAuthenticated({
80
+ jwt: result.jwt,
81
+ subject: provisional,
82
+ expiresAt: result.validUntil ?? null,
83
+ ...(storage ? { storage } : {}),
84
+ });
85
+ // Best-effort hydrate the subject from /me. If this fails we keep the
86
+ // provisional subject; consumers can retry via profile.fetch().
87
+ try {
88
+ await this.#profile.fetch();
89
+ }
90
+ catch {
91
+ // swallow — session stays authenticated with the provisional subject
92
+ }
93
+ if (this.#onIdentityChanged) {
94
+ try {
95
+ await this.#onIdentityChanged(this.#ctx());
96
+ }
97
+ catch {
98
+ // swallow — hook errors must not destabilize auth state
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+ // ─────────────────────── verbs ──────────────────────────────────────────
104
+ async register(input, options) {
105
+ const result = await this.#adapter.register(input, this.#ctx());
106
+ this.#pubsub.publish("auth:register", {
107
+ type: "auth:register",
108
+ timestamp: Date.now(),
109
+ email: input.email,
110
+ requiresVerification: Boolean(result.requiresVerification),
111
+ });
112
+ return await this.#applyAuthResult(result, options?.remember);
113
+ }
114
+ async login(input, options) {
115
+ const result = await this.#adapter.login(input, this.#ctx());
116
+ this.#pubsub.publish("auth:login", {
117
+ type: "auth:login",
118
+ timestamp: Date.now(),
119
+ email: input.email,
120
+ });
121
+ return await this.#applyAuthResult(result, options?.remember);
122
+ }
123
+ async logout() {
124
+ const subjectId = this.#session.get().subject?.id;
125
+ try {
126
+ await this.#adapter.logout(this.#ctx());
127
+ }
128
+ catch {
129
+ // server logout is best-effort — still clear locally
130
+ }
131
+ this.#session.clear();
132
+ this.#pubsub.publish("auth:logout", {
133
+ type: "auth:logout",
134
+ timestamp: Date.now(),
135
+ subjectId,
136
+ });
137
+ if (this.#onIdentityChanged) {
138
+ try {
139
+ await this.#onIdentityChanged(this.#ctx());
140
+ }
141
+ catch {
142
+ // swallow
143
+ }
144
+ }
145
+ }
146
+ async resendVerification(input) {
147
+ await this.#adapter.resendVerification(input, this.#ctx());
148
+ }
149
+ async requestPasswordReset(input) {
150
+ await this.#adapter.requestPasswordReset(input, this.#ctx());
151
+ }
152
+ async changePassword(input) {
153
+ await this.#adapter.changePassword(input, this.#ctx());
154
+ }
155
+ async deleteAccount(input) {
156
+ await this.#adapter.deleteAccount(input, this.#ctx());
157
+ // Clear session locally and fire logout/identity-change.
158
+ this.#session.clear();
159
+ this.#pubsub.publish("auth:logout", {
160
+ type: "auth:logout",
161
+ timestamp: Date.now(),
162
+ });
163
+ if (this.#onIdentityChanged) {
164
+ try {
165
+ await this.#onIdentityChanged(this.#ctx());
166
+ }
167
+ catch {
168
+ // swallow
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Begin an OAuth flow. Returns:
174
+ * - For popup mode: a Promise that resolves when the popup posts back
175
+ * an auth result.
176
+ * - For redirect mode: nothing useful; the top window navigates away.
177
+ */
178
+ async initiateOAuth(provider, opts) {
179
+ const url = this.#adapter.oauthInitUrl(provider, opts, this.#ctx());
180
+ const mode = opts.mode ?? "popup";
181
+ if (mode === "redirect") {
182
+ if (typeof globalThis !== "undefined" && "location" in globalThis) {
183
+ globalThis.location.href = url;
184
+ }
185
+ return;
186
+ }
187
+ // popup mode — wait for postMessage.
188
+ const message = await openOAuthPopup(url);
189
+ if (message.type === "oauth_link_success") {
190
+ this.#pubsub.publish("oauth:linked", {
191
+ type: "oauth:linked",
192
+ timestamp: Date.now(),
193
+ connection: { provider },
194
+ });
195
+ // Refresh profile so the new connection appears.
196
+ try {
197
+ await this.#profile.fetch();
198
+ }
199
+ catch {
200
+ // swallow
201
+ }
202
+ return;
203
+ }
204
+ // login result
205
+ const result = {
206
+ jwt: message.jwt,
207
+ email: message.email,
208
+ roles: message.roles ?? [],
209
+ isVerified: true,
210
+ };
211
+ return await this.#applyAuthResult(result, opts.remember);
212
+ }
213
+ /** For the redirect-mode callback page: delegate to the adapter if
214
+ * available to extract the result from the current URL/page state.
215
+ * `options.remember` pins the resulting session to local/session
216
+ * storage — pass the same value the user picked before the redirect. */
217
+ async handleOAuthCallback(options) {
218
+ if (!this.#adapter.handleOAuthCallback) {
219
+ throw new Error("AuthManager: handleOAuthCallback is not implemented by the adapter");
220
+ }
221
+ const result = await this.#adapter.handleOAuthCallback(this.#ctx());
222
+ return await this.#applyAuthResult(result, options?.remember);
223
+ }
224
+ }
@@ -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,92 @@
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 by
38
+ * probing the built-in backends in order `local → session → memory` and
39
+ * adopting whichever holds a non-expired payload as the active backend for
40
+ * the rest of the instance's lifetime (until `clear()`). Stale blobs on the
41
+ * other built-in backends are wiped on adoption.
42
+ *
43
+ * When constructed with a custom `SessionStorage` object, there is a single
44
+ * backend and per-login `remember` choices are silently ignored.
45
+ */
46
+ export declare class SessionManager {
47
+ #private;
48
+ constructor(options?: SessionManagerOptions);
49
+ /** Svelte-compatible subscribe. */
50
+ get subscribe(): StoreLike<SessionState>["subscribe"];
51
+ /** Current session state snapshot. */
52
+ get(): SessionState;
53
+ get isAuthenticated(): boolean;
54
+ get isUnverified(): boolean;
55
+ get isAnonymous(): boolean;
56
+ /** JWT for adapter `Authorization` headers, or null when anonymous. */
57
+ getJwt(): string | null;
58
+ /** Transition to authenticated. Called after login/register/OAuth succeed
59
+ * and the subject has been loaded.
60
+ *
61
+ * When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
62
+ * pins this session to that built-in backend; subsequent
63
+ * `patchSubject` / `setUnverified` writes land on the same backend.
64
+ * The previously-active backend's blob is wiped as part of the switch
65
+ * so "Remember me" toggles don't leave stale data behind.
66
+ *
67
+ * Ignored when the manager was constructed with a custom `SessionStorage`
68
+ * object (single backend) or when `opts.storage` is itself an object
69
+ * (no multi-backend custom storage by design). */
70
+ setAuthenticated(opts: {
71
+ jwt: string;
72
+ subject: SessionSubject;
73
+ expiresAt?: number | null;
74
+ storage?: SessionStorageType;
75
+ }): void;
76
+ /** Server confirmed credentials but refuses login until email is
77
+ * verified. Expose the email so the UI can prompt "check your inbox"
78
+ * without a second server call. No JWT in this state. */
79
+ setUnverified(email: string): void;
80
+ /** Drop the session. Every built-in backend (local + session + memory)
81
+ * is wiped — not just the active one — so stale blobs from a previous
82
+ * "Remember me" toggle can't leak back in on the next construction.
83
+ * Resets the active backend to the manager's configured default.
84
+ * Downstream domains should be reset by the suite orchestrator. */
85
+ clear(): void;
86
+ /** Patch the subject in place without touching the JWT. Used when the
87
+ * profile manager changes email / linked providers / etc. */
88
+ patchSubject(patch: Partial<SessionSubject>): void;
89
+ /** Test / reset hook — clears every backend AND in-memory state without
90
+ * emitting. Used by teardown. */
91
+ destroy(): void;
92
+ }