@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.
- package/AGENTS.md +88 -9
- package/API.md +332 -1
- package/README.md +65 -0
- package/dist/adapters/mock-auth.d.ts +38 -0
- package/dist/adapters/mock-auth.js +237 -0
- package/dist/adapters/mod.d.ts +2 -0
- package/dist/adapters/mod.js +2 -0
- package/dist/adapters/stack-account.d.ts +38 -0
- package/dist/adapters/stack-account.js +149 -0
- package/dist/domains/auth.d.ts +83 -0
- package/dist/domains/auth.js +211 -0
- package/dist/domains/mod.d.ts +3 -0
- package/dist/domains/mod.js +3 -0
- package/dist/domains/profile.d.ts +62 -0
- package/dist/domains/profile.js +170 -0
- package/dist/domains/session.d.ts +73 -0
- package/dist/domains/session.js +226 -0
- package/dist/mod.d.ts +1 -0
- package/dist/mod.js +1 -0
- package/dist/oauth/popup.d.ts +64 -0
- package/dist/oauth/popup.js +104 -0
- package/dist/ownsuite.d.ts +21 -0
- package/dist/ownsuite.js +86 -0
- package/dist/types/auth.d.ts +162 -0
- package/dist/types/auth.js +17 -0
- package/dist/types/events.d.ts +41 -2
- package/dist/types/mod.d.ts +1 -0
- package/dist/types/mod.js +1 -0
- package/dist/types/state.d.ts +7 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/domains/mod.d.ts
CHANGED
package/dist/domains/mod.js
CHANGED
|
@@ -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
|
+
}
|