@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.
- package/AGENTS.md +88 -9
- package/API.md +342 -1
- package/README.md +68 -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 +85 -0
- package/dist/domains/auth.js +224 -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 +92 -0
- package/dist/domains/session.js +323 -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 +182 -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,323 @@
|
|
|
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 { createStore } from "@marianmeres/store";
|
|
17
|
+
import { createPubSub } from "@marianmeres/pubsub";
|
|
18
|
+
const DEFAULT_STORAGE_KEY = "ownsuite:session";
|
|
19
|
+
const BUILT_IN_ORDER = [
|
|
20
|
+
"local",
|
|
21
|
+
"session",
|
|
22
|
+
"memory",
|
|
23
|
+
];
|
|
24
|
+
const EMPTY = {
|
|
25
|
+
status: "anonymous",
|
|
26
|
+
subject: null,
|
|
27
|
+
jwt: null,
|
|
28
|
+
expiresAt: null,
|
|
29
|
+
};
|
|
30
|
+
/** In-memory session storage — survives the current JS realm only. */
|
|
31
|
+
export function createMemorySessionStorage() {
|
|
32
|
+
const map = new Map();
|
|
33
|
+
return {
|
|
34
|
+
get(k) {
|
|
35
|
+
return map.has(k) ? map.get(k) : null;
|
|
36
|
+
},
|
|
37
|
+
set(k, v) {
|
|
38
|
+
map.set(k, v);
|
|
39
|
+
},
|
|
40
|
+
del(k) {
|
|
41
|
+
map.delete(k);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
|
|
46
|
+
* Gracefully falls back to memory storage when running in an environment
|
|
47
|
+
* without Web Storage (SSR, worker without storage, etc). */
|
|
48
|
+
export function resolveSessionStorage(type = "local") {
|
|
49
|
+
if (typeof type === "object" && type !== null)
|
|
50
|
+
return type;
|
|
51
|
+
if (type === "memory")
|
|
52
|
+
return createMemorySessionStorage();
|
|
53
|
+
const backend = type === "session"
|
|
54
|
+
? "sessionStorage"
|
|
55
|
+
: "localStorage";
|
|
56
|
+
const g = globalThis;
|
|
57
|
+
const store = g[backend];
|
|
58
|
+
if (!store)
|
|
59
|
+
return createMemorySessionStorage();
|
|
60
|
+
return {
|
|
61
|
+
get(k) {
|
|
62
|
+
try {
|
|
63
|
+
return store.getItem(k);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
set(k, v) {
|
|
70
|
+
try {
|
|
71
|
+
store.setItem(k, v);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// quota exceeded / disabled — degrade silently
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
del(k) {
|
|
78
|
+
try {
|
|
79
|
+
store.removeItem(k);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Session manager — pure reactive state + persistence, no HTTP.
|
|
89
|
+
*
|
|
90
|
+
* Writes are driven by the AuthManager. On construction it hydrates by
|
|
91
|
+
* probing the built-in backends in order `local → session → memory` and
|
|
92
|
+
* adopting whichever holds a non-expired payload as the active backend for
|
|
93
|
+
* the rest of the instance's lifetime (until `clear()`). Stale blobs on the
|
|
94
|
+
* other built-in backends are wiped on adoption.
|
|
95
|
+
*
|
|
96
|
+
* When constructed with a custom `SessionStorage` object, there is a single
|
|
97
|
+
* backend and per-login `remember` choices are silently ignored.
|
|
98
|
+
*/
|
|
99
|
+
export class SessionManager {
|
|
100
|
+
#store;
|
|
101
|
+
#pubsub;
|
|
102
|
+
#storageKey;
|
|
103
|
+
/** Set only when the consumer passed a `SessionStorage` object at
|
|
104
|
+
* construction — then there is one backend and no toggling. */
|
|
105
|
+
#customStorage;
|
|
106
|
+
/** Resolved eagerly in the string-storage case so `clear()` can wipe
|
|
107
|
+
* every backend and per-login overrides can switch between them. */
|
|
108
|
+
#builtIn;
|
|
109
|
+
#defaultStorageType;
|
|
110
|
+
#activeStorage;
|
|
111
|
+
#activeStorageType;
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
this.#pubsub = options.pubsub ?? createPubSub();
|
|
114
|
+
this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
115
|
+
this.#store = createStore({ ...EMPTY });
|
|
116
|
+
const configured = options.storage ?? "local";
|
|
117
|
+
if (typeof configured === "string") {
|
|
118
|
+
this.#customStorage = null;
|
|
119
|
+
this.#builtIn = {
|
|
120
|
+
local: resolveSessionStorage("local"),
|
|
121
|
+
session: resolveSessionStorage("session"),
|
|
122
|
+
memory: createMemorySessionStorage(),
|
|
123
|
+
};
|
|
124
|
+
this.#defaultStorageType = configured;
|
|
125
|
+
this.#activeStorage = this.#builtIn[configured];
|
|
126
|
+
this.#activeStorageType = configured;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.#customStorage = configured;
|
|
130
|
+
this.#builtIn = null;
|
|
131
|
+
this.#defaultStorageType = "local";
|
|
132
|
+
this.#activeStorage = configured;
|
|
133
|
+
this.#activeStorageType = "custom";
|
|
134
|
+
}
|
|
135
|
+
this.#hydrate();
|
|
136
|
+
}
|
|
137
|
+
/** Try to parse a payload and validate shape + expiry. Returns the state
|
|
138
|
+
* on success, or `null` (and deletes the stored blob) on any failure. */
|
|
139
|
+
#readCandidate(storage) {
|
|
140
|
+
const raw = storage.get(this.#storageKey);
|
|
141
|
+
if (!raw)
|
|
142
|
+
return null;
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
if (typeof parsed !== "object" ||
|
|
146
|
+
parsed === null ||
|
|
147
|
+
typeof parsed.status !== "string") {
|
|
148
|
+
storage.del(this.#storageKey);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (parsed.expiresAt !== null &&
|
|
152
|
+
parsed.expiresAt !== undefined &&
|
|
153
|
+
parsed.expiresAt * 1000 <= Date.now()) {
|
|
154
|
+
storage.del(this.#storageKey);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return parsed;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
storage.del(this.#storageKey);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Read from storage and populate the store. Expired sessions are wiped.
|
|
165
|
+
* In the built-in case, probes `local → session → memory` and wipes
|
|
166
|
+
* the losing backends so stale blobs can't leak back in. */
|
|
167
|
+
#hydrate() {
|
|
168
|
+
if (this.#customStorage) {
|
|
169
|
+
const parsed = this.#readCandidate(this.#customStorage);
|
|
170
|
+
if (parsed)
|
|
171
|
+
this.#store.set(parsed);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const builtIn = this.#builtIn;
|
|
175
|
+
for (const type of BUILT_IN_ORDER) {
|
|
176
|
+
const parsed = this.#readCandidate(builtIn[type]);
|
|
177
|
+
if (!parsed)
|
|
178
|
+
continue;
|
|
179
|
+
// Adopt this backend; wipe the others so a later login-with-toggle
|
|
180
|
+
// can't accidentally re-hydrate a stale blob.
|
|
181
|
+
for (const other of BUILT_IN_ORDER) {
|
|
182
|
+
if (other !== type)
|
|
183
|
+
builtIn[other].del(this.#storageKey);
|
|
184
|
+
}
|
|
185
|
+
this.#activeStorage = builtIn[type];
|
|
186
|
+
this.#activeStorageType = type;
|
|
187
|
+
this.#store.set(parsed);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
#persist() {
|
|
192
|
+
const s = this.#store.get();
|
|
193
|
+
if (s.status === "anonymous") {
|
|
194
|
+
this.#activeStorage.del(this.#storageKey);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
this.#activeStorage.set(this.#storageKey, JSON.stringify(s));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
#emitChange() {
|
|
201
|
+
this.#pubsub.publish("auth:session:changed", {
|
|
202
|
+
type: "auth:session:changed",
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
session: this.#store.get(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Svelte-compatible subscribe. */
|
|
208
|
+
get subscribe() {
|
|
209
|
+
return this.#store.subscribe;
|
|
210
|
+
}
|
|
211
|
+
/** Current session state snapshot. */
|
|
212
|
+
get() {
|
|
213
|
+
return this.#store.get();
|
|
214
|
+
}
|
|
215
|
+
get isAuthenticated() {
|
|
216
|
+
return this.#store.get().status === "authenticated";
|
|
217
|
+
}
|
|
218
|
+
get isUnverified() {
|
|
219
|
+
return this.#store.get().status === "unverified";
|
|
220
|
+
}
|
|
221
|
+
get isAnonymous() {
|
|
222
|
+
return this.#store.get().status === "anonymous";
|
|
223
|
+
}
|
|
224
|
+
/** JWT for adapter `Authorization` headers, or null when anonymous. */
|
|
225
|
+
getJwt() {
|
|
226
|
+
return this.#store.get().jwt;
|
|
227
|
+
}
|
|
228
|
+
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
229
|
+
* and the subject has been loaded.
|
|
230
|
+
*
|
|
231
|
+
* When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
|
|
232
|
+
* pins this session to that built-in backend; subsequent
|
|
233
|
+
* `patchSubject` / `setUnverified` writes land on the same backend.
|
|
234
|
+
* The previously-active backend's blob is wiped as part of the switch
|
|
235
|
+
* so "Remember me" toggles don't leave stale data behind.
|
|
236
|
+
*
|
|
237
|
+
* Ignored when the manager was constructed with a custom `SessionStorage`
|
|
238
|
+
* object (single backend) or when `opts.storage` is itself an object
|
|
239
|
+
* (no multi-backend custom storage by design). */
|
|
240
|
+
setAuthenticated(opts) {
|
|
241
|
+
const { jwt, subject, expiresAt = null, storage } = opts;
|
|
242
|
+
if (typeof storage === "string" && this.#builtIn) {
|
|
243
|
+
if (this.#activeStorage !== this.#builtIn[storage]) {
|
|
244
|
+
this.#activeStorage.del(this.#storageKey);
|
|
245
|
+
this.#activeStorage = this.#builtIn[storage];
|
|
246
|
+
this.#activeStorageType = storage;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this.#store.set({
|
|
250
|
+
status: "authenticated",
|
|
251
|
+
subject,
|
|
252
|
+
jwt,
|
|
253
|
+
expiresAt,
|
|
254
|
+
});
|
|
255
|
+
this.#persist();
|
|
256
|
+
this.#emitChange();
|
|
257
|
+
}
|
|
258
|
+
/** Server confirmed credentials but refuses login until email is
|
|
259
|
+
* verified. Expose the email so the UI can prompt "check your inbox"
|
|
260
|
+
* without a second server call. No JWT in this state. */
|
|
261
|
+
setUnverified(email) {
|
|
262
|
+
this.#store.set({
|
|
263
|
+
status: "unverified",
|
|
264
|
+
subject: {
|
|
265
|
+
id: "",
|
|
266
|
+
email,
|
|
267
|
+
roles: [],
|
|
268
|
+
isVerified: false,
|
|
269
|
+
hasPassword: false,
|
|
270
|
+
},
|
|
271
|
+
jwt: null,
|
|
272
|
+
expiresAt: null,
|
|
273
|
+
});
|
|
274
|
+
this.#persist();
|
|
275
|
+
this.#emitChange();
|
|
276
|
+
}
|
|
277
|
+
/** Drop the session. Every built-in backend (local + session + memory)
|
|
278
|
+
* is wiped — not just the active one — so stale blobs from a previous
|
|
279
|
+
* "Remember me" toggle can't leak back in on the next construction.
|
|
280
|
+
* Resets the active backend to the manager's configured default.
|
|
281
|
+
* Downstream domains should be reset by the suite orchestrator. */
|
|
282
|
+
clear() {
|
|
283
|
+
this.#wipeAllBackends();
|
|
284
|
+
if (this.#builtIn) {
|
|
285
|
+
this.#activeStorage = this.#builtIn[this.#defaultStorageType];
|
|
286
|
+
this.#activeStorageType = this.#defaultStorageType;
|
|
287
|
+
}
|
|
288
|
+
if (this.#store.get().status === "anonymous")
|
|
289
|
+
return;
|
|
290
|
+
this.#store.set({ ...EMPTY });
|
|
291
|
+
this.#emitChange();
|
|
292
|
+
}
|
|
293
|
+
#wipeAllBackends() {
|
|
294
|
+
if (this.#customStorage) {
|
|
295
|
+
this.#customStorage.del(this.#storageKey);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const builtIn = this.#builtIn;
|
|
299
|
+
for (const type of BUILT_IN_ORDER) {
|
|
300
|
+
builtIn[type].del(this.#storageKey);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Patch the subject in place without touching the JWT. Used when the
|
|
304
|
+
* profile manager changes email / linked providers / etc. */
|
|
305
|
+
patchSubject(patch) {
|
|
306
|
+
const s = this.#store.get();
|
|
307
|
+
if (!s.subject)
|
|
308
|
+
return;
|
|
309
|
+
const next = {
|
|
310
|
+
...s,
|
|
311
|
+
subject: { ...s.subject, ...patch },
|
|
312
|
+
};
|
|
313
|
+
this.#store.set(next);
|
|
314
|
+
this.#persist();
|
|
315
|
+
this.#emitChange();
|
|
316
|
+
}
|
|
317
|
+
/** Test / reset hook — clears every backend AND in-memory state without
|
|
318
|
+
* emitting. Used by teardown. */
|
|
319
|
+
destroy() {
|
|
320
|
+
this.#wipeAllBackends();
|
|
321
|
+
this.#store.set({ ...EMPTY });
|
|
322
|
+
}
|
|
323
|
+
}
|
package/dist/mod.d.ts
CHANGED
package/dist/mod.js
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module oauth/popup
|
|
3
|
+
*
|
|
4
|
+
* `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
|
|
5
|
+
* resolves with the postMessage the server's callback page posts back.
|
|
6
|
+
*
|
|
7
|
+
* Server shape (stack-account): the OAuth callback page renders a `<script>`
|
|
8
|
+
* that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
|
|
9
|
+
* We listen for those messages, validate the `type` field, and resolve.
|
|
10
|
+
*
|
|
11
|
+
* For tests, an injectable window-like interface lets us shim `postMessage`
|
|
12
|
+
* via a MessageChannel without a real browser.
|
|
13
|
+
*/
|
|
14
|
+
export interface OAuthPopupLoginMessage {
|
|
15
|
+
type: "oauth_login_success";
|
|
16
|
+
jwt: string;
|
|
17
|
+
email: string;
|
|
18
|
+
roles?: string[];
|
|
19
|
+
isNewAccount?: boolean;
|
|
20
|
+
redirectUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface OAuthPopupLinkMessage {
|
|
23
|
+
type: "oauth_link_success";
|
|
24
|
+
provider: string;
|
|
25
|
+
}
|
|
26
|
+
export interface OAuthPopupErrorMessage {
|
|
27
|
+
type: "oauth_error";
|
|
28
|
+
error: string;
|
|
29
|
+
}
|
|
30
|
+
export type OAuthPopupMessage = OAuthPopupLoginMessage | OAuthPopupLinkMessage;
|
|
31
|
+
/**
|
|
32
|
+
* Minimal window-like surface we need. Real `Window` satisfies this; tests
|
|
33
|
+
* supply a shim.
|
|
34
|
+
*/
|
|
35
|
+
export interface PopupWindowHost {
|
|
36
|
+
open(url: string, target: string, features?: string): PopupWindowHandle | null;
|
|
37
|
+
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
38
|
+
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
39
|
+
}
|
|
40
|
+
export interface PopupWindowHandle {
|
|
41
|
+
closed: boolean;
|
|
42
|
+
focus?(): void;
|
|
43
|
+
close?(): void;
|
|
44
|
+
}
|
|
45
|
+
export interface OpenOAuthPopupOptions {
|
|
46
|
+
/** Host window — defaults to `globalThis` when running in the browser. */
|
|
47
|
+
host?: PopupWindowHost;
|
|
48
|
+
/** How often to poll for popup-closed-without-message. Default 500ms. */
|
|
49
|
+
closedPollMs?: number;
|
|
50
|
+
/** Hard timeout in ms; 0 disables. Default 0. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** Restrict accepted message origins. Default: no origin check (the
|
|
53
|
+
* server's postMessage uses "*" intentionally; origin validation is the
|
|
54
|
+
* caller's responsibility if needed). */
|
|
55
|
+
expectedOrigin?: string;
|
|
56
|
+
/** Popup window features string. */
|
|
57
|
+
features?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open the OAuth popup at `url` and await a success message from it.
|
|
61
|
+
* Rejects if the popup is closed before a message arrives, if the received
|
|
62
|
+
* message is an error, or if the optional timeout fires.
|
|
63
|
+
*/
|
|
64
|
+
export declare function openOAuthPopup(url: string, options?: OpenOAuthPopupOptions): Promise<OAuthPopupMessage>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module oauth/popup
|
|
3
|
+
*
|
|
4
|
+
* `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
|
|
5
|
+
* resolves with the postMessage the server's callback page posts back.
|
|
6
|
+
*
|
|
7
|
+
* Server shape (stack-account): the OAuth callback page renders a `<script>`
|
|
8
|
+
* that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
|
|
9
|
+
* We listen for those messages, validate the `type` field, and resolve.
|
|
10
|
+
*
|
|
11
|
+
* For tests, an injectable window-like interface lets us shim `postMessage`
|
|
12
|
+
* via a MessageChannel without a real browser.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_FEATURES = "width=500,height=700,noopener=no,noreferrer=no";
|
|
15
|
+
/**
|
|
16
|
+
* Open the OAuth popup at `url` and await a success message from it.
|
|
17
|
+
* Rejects if the popup is closed before a message arrives, if the received
|
|
18
|
+
* message is an error, or if the optional timeout fires.
|
|
19
|
+
*/
|
|
20
|
+
export function openOAuthPopup(url, options = {}) {
|
|
21
|
+
const host = (options.host ?? globalThis);
|
|
22
|
+
if (typeof host.open !== "function" || typeof host.addEventListener !== "function") {
|
|
23
|
+
return Promise.reject(new Error("openOAuthPopup: host window does not support popups"));
|
|
24
|
+
}
|
|
25
|
+
const features = options.features ?? DEFAULT_FEATURES;
|
|
26
|
+
const closedPollMs = options.closedPollMs ?? 500;
|
|
27
|
+
const popup = host.open(url, "oauth_popup", features);
|
|
28
|
+
if (!popup) {
|
|
29
|
+
return Promise.reject(new Error("OAUTH_POPUP_BLOCKED"));
|
|
30
|
+
}
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
let pollTimer;
|
|
34
|
+
let timeoutTimer;
|
|
35
|
+
const cleanup = () => {
|
|
36
|
+
settled = true;
|
|
37
|
+
host.removeEventListener("message", onMessage);
|
|
38
|
+
if (pollTimer !== undefined)
|
|
39
|
+
clearInterval(pollTimer);
|
|
40
|
+
if (timeoutTimer !== undefined)
|
|
41
|
+
clearTimeout(timeoutTimer);
|
|
42
|
+
};
|
|
43
|
+
const onMessage = (event) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
if (options.expectedOrigin !== undefined &&
|
|
47
|
+
event.origin !== options.expectedOrigin) {
|
|
48
|
+
return; // silently drop mismatched origin messages
|
|
49
|
+
}
|
|
50
|
+
const data = event.data;
|
|
51
|
+
if (!data || typeof data !== "object" || typeof data.type !== "string") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (data.type === "oauth_login_success" ||
|
|
55
|
+
data.type === "oauth_link_success") {
|
|
56
|
+
cleanup();
|
|
57
|
+
try {
|
|
58
|
+
popup.close?.();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
resolve(data);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (data.type === "oauth_error") {
|
|
67
|
+
cleanup();
|
|
68
|
+
try {
|
|
69
|
+
popup.close?.();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
reject(new Error(data.error));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
host.addEventListener("message", onMessage);
|
|
79
|
+
if (closedPollMs > 0) {
|
|
80
|
+
pollTimer = setInterval(() => {
|
|
81
|
+
if (settled)
|
|
82
|
+
return;
|
|
83
|
+
if (popup.closed) {
|
|
84
|
+
cleanup();
|
|
85
|
+
reject(new Error("OAUTH_POPUP_CLOSED"));
|
|
86
|
+
}
|
|
87
|
+
}, closedPollMs);
|
|
88
|
+
}
|
|
89
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
90
|
+
timeoutTimer = setTimeout(() => {
|
|
91
|
+
if (settled)
|
|
92
|
+
return;
|
|
93
|
+
cleanup();
|
|
94
|
+
try {
|
|
95
|
+
popup.close?.();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore
|
|
99
|
+
}
|
|
100
|
+
reject(new Error("OAUTH_POPUP_TIMEOUT"));
|
|
101
|
+
}, options.timeoutMs);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
package/dist/ownsuite.d.ts
CHANGED
|
@@ -14,7 +14,11 @@ import { type Subscriber, type Unsubscriber } from "@marianmeres/pubsub";
|
|
|
14
14
|
import type { DomainError, OwnsuiteContext } from "./types/state.js";
|
|
15
15
|
import type { OwnedCollectionAdapter } from "./types/adapter.js";
|
|
16
16
|
import type { OwnsuiteEventType } from "./types/events.js";
|
|
17
|
+
import type { AuthAdapter, ProfileAdapter, SessionStorageType } from "./types/auth.js";
|
|
17
18
|
import { OwnedCollectionManager } from "./domains/owned-collection.js";
|
|
19
|
+
import { AuthManager } from "./domains/auth.js";
|
|
20
|
+
import { ProfileManager } from "./domains/profile.js";
|
|
21
|
+
import { SessionManager } from "./domains/session.js";
|
|
18
22
|
/**
|
|
19
23
|
* Configuration for a single domain at construction time. The caller
|
|
20
24
|
* provides a unique name and an adapter. A custom `getRowId` is optional
|
|
@@ -32,6 +36,18 @@ export interface OwnsuiteConfig {
|
|
|
32
36
|
domains?: Record<string, OwnsuiteDomainConfig>;
|
|
33
37
|
/** Auto-initialize all registered domains on creation (default: false). */
|
|
34
38
|
autoInitialize?: boolean;
|
|
39
|
+
/** Auth / profile adapters. When present, ownsuite builds the
|
|
40
|
+
* SessionManager / AuthManager / ProfileManager trio and exposes them
|
|
41
|
+
* as `suite.auth`, `suite.profile`, `suite.session`. */
|
|
42
|
+
adapters?: {
|
|
43
|
+
auth?: AuthAdapter;
|
|
44
|
+
profile?: ProfileAdapter;
|
|
45
|
+
};
|
|
46
|
+
/** Session persistence config. Ignored when no auth adapter is provided. */
|
|
47
|
+
session?: {
|
|
48
|
+
storage?: SessionStorageType;
|
|
49
|
+
storageKey?: string;
|
|
50
|
+
};
|
|
35
51
|
}
|
|
36
52
|
/** Options for {@link Ownsuite.setContext}. */
|
|
37
53
|
export interface SetContextOptions {
|
|
@@ -60,6 +76,11 @@ export interface SetContextOptions {
|
|
|
60
76
|
*/
|
|
61
77
|
export declare class Ownsuite {
|
|
62
78
|
#private;
|
|
79
|
+
/** Optional auth/session/profile managers. Present iff `adapters.auth`
|
|
80
|
+
* was supplied at construction. */
|
|
81
|
+
readonly session: SessionManager | null;
|
|
82
|
+
readonly auth: AuthManager | null;
|
|
83
|
+
readonly profile: ProfileManager | null;
|
|
63
84
|
constructor(config?: OwnsuiteConfig);
|
|
64
85
|
/** True after `destroy()` has been called. */
|
|
65
86
|
get isDestroyed(): boolean;
|
package/dist/ownsuite.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
import { createClog } from "@marianmeres/clog";
|
|
14
14
|
import { createPubSub, } from "@marianmeres/pubsub";
|
|
15
15
|
import { OwnedCollectionManager } from "./domains/owned-collection.js";
|
|
16
|
+
import { AuthManager } from "./domains/auth.js";
|
|
17
|
+
import { ProfileManager } from "./domains/profile.js";
|
|
18
|
+
import { SessionManager } from "./domains/session.js";
|
|
16
19
|
/**
|
|
17
20
|
* Main Ownsuite class — coordinates owner-scoped domain managers.
|
|
18
21
|
*
|
|
@@ -38,9 +41,70 @@ export class Ownsuite {
|
|
|
38
41
|
// deno-lint-ignore no-explicit-any
|
|
39
42
|
#domains = new Map();
|
|
40
43
|
#destroyed = false;
|
|
44
|
+
/** Optional auth/session/profile managers. Present iff `adapters.auth`
|
|
45
|
+
* was supplied at construction. */
|
|
46
|
+
session = null;
|
|
47
|
+
auth = null;
|
|
48
|
+
profile = null;
|
|
41
49
|
constructor(config = {}) {
|
|
42
50
|
this.#pubsub = createPubSub();
|
|
43
51
|
this.#context = { ...(config.context ?? {}) };
|
|
52
|
+
// Build session / auth / profile managers if the auth adapter is wired.
|
|
53
|
+
if (config.adapters?.auth) {
|
|
54
|
+
const session = new SessionManager({
|
|
55
|
+
storage: config.session?.storage,
|
|
56
|
+
storageKey: config.session?.storageKey,
|
|
57
|
+
pubsub: this.#pubsub,
|
|
58
|
+
});
|
|
59
|
+
this.session = session;
|
|
60
|
+
// Profile adapter is optional but strongly encouraged. Without it
|
|
61
|
+
// login still works — we just don't hydrate roles/isVerified from
|
|
62
|
+
// /me after auth and consumers must call auth-result-based state
|
|
63
|
+
// themselves.
|
|
64
|
+
const profileAdapter = config.adapters.profile;
|
|
65
|
+
if (profileAdapter) {
|
|
66
|
+
this.profile = new ProfileManager({
|
|
67
|
+
adapter: profileAdapter,
|
|
68
|
+
session,
|
|
69
|
+
pubsub: this.#pubsub,
|
|
70
|
+
context: this.#context,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// AuthManager requires a profile manager to hydrate the subject
|
|
74
|
+
// after login. If none was provided, build a stub that throws —
|
|
75
|
+
// AuthManager will still call it inside a try/catch and recover.
|
|
76
|
+
const profileForAuth = this.profile ?? new ProfileManager({
|
|
77
|
+
adapter: {
|
|
78
|
+
get: () => Promise.reject(new Error("no profile adapter configured")),
|
|
79
|
+
update: () => Promise.reject(new Error("no profile adapter configured")),
|
|
80
|
+
listOAuth: () => Promise.resolve([]),
|
|
81
|
+
unlinkOAuth: () => Promise.reject(new Error("no profile adapter configured")),
|
|
82
|
+
},
|
|
83
|
+
session,
|
|
84
|
+
pubsub: this.#pubsub,
|
|
85
|
+
context: this.#context,
|
|
86
|
+
});
|
|
87
|
+
this.auth = new AuthManager({
|
|
88
|
+
adapter: config.adapters.auth,
|
|
89
|
+
session,
|
|
90
|
+
profile: profileForAuth,
|
|
91
|
+
pubsub: this.#pubsub,
|
|
92
|
+
context: this.#context,
|
|
93
|
+
onIdentityChanged: (ctx) => this.#onIdentityChanged(ctx),
|
|
94
|
+
});
|
|
95
|
+
// Listen for session changes to keep the suite-wide context
|
|
96
|
+
// `jwt` / `subjectId` in sync with the authenticated session.
|
|
97
|
+
session.subscribe((s) => {
|
|
98
|
+
const patch = {
|
|
99
|
+
jwt: s.jwt ?? undefined,
|
|
100
|
+
subjectId: s.subject?.id || undefined,
|
|
101
|
+
};
|
|
102
|
+
this.#context = { ...this.#context, ...patch };
|
|
103
|
+
for (const m of this.#domains.values()) {
|
|
104
|
+
m.setContext(patch);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
44
108
|
for (const [name, cfg] of Object.entries(config.domains ?? {})) {
|
|
45
109
|
this.registerDomain(name, cfg);
|
|
46
110
|
}
|
|
@@ -51,6 +115,26 @@ export class Ownsuite {
|
|
|
51
115
|
void this.initialize();
|
|
52
116
|
}
|
|
53
117
|
}
|
|
118
|
+
/** Identity-change hook fired by the AuthManager after a successful
|
|
119
|
+
* login / register / logout / OAuth login. Reset every owner-scoped
|
|
120
|
+
* domain so their subscribed state reflects the new subject (or the
|
|
121
|
+
* anonymous state). Subsequent fetches pick up the new ctx.jwt. */
|
|
122
|
+
async #onIdentityChanged(ctx) {
|
|
123
|
+
for (const m of this.#domains.values())
|
|
124
|
+
m.setContext(ctx);
|
|
125
|
+
for (const m of this.#domains.values())
|
|
126
|
+
m.reset();
|
|
127
|
+
// Re-init for authenticated state; stay at "initializing" for logout
|
|
128
|
+
// (so stale data doesn't flash while consumers unmount).
|
|
129
|
+
if (ctx.jwt) {
|
|
130
|
+
try {
|
|
131
|
+
await this.initialize();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// initialize() doesn't reject on domain errors, but guard anyway.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
54
138
|
/** True after `destroy()` has been called. */
|
|
55
139
|
get isDestroyed() {
|
|
56
140
|
return this.#destroyed;
|
|
@@ -190,6 +274,8 @@ export class Ownsuite {
|
|
|
190
274
|
for (const m of this.#domains.values())
|
|
191
275
|
m.destroy();
|
|
192
276
|
this.#domains.clear();
|
|
277
|
+
this.profile?.destroy();
|
|
278
|
+
this.session?.destroy();
|
|
193
279
|
// Our internal pubsub: clear all subscribers. Best-effort — if a custom
|
|
194
280
|
// pubsub implementation doesn't expose `unsubscribeAll`, skip it.
|
|
195
281
|
const ps = this.#pubsub;
|