@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,226 @@
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 EMPTY = {
20
+ status: "anonymous",
21
+ subject: null,
22
+ jwt: null,
23
+ expiresAt: null,
24
+ };
25
+ /** In-memory session storage — survives the current JS realm only. */
26
+ export function createMemorySessionStorage() {
27
+ const map = new Map();
28
+ return {
29
+ get(k) {
30
+ return map.has(k) ? map.get(k) : null;
31
+ },
32
+ set(k, v) {
33
+ map.set(k, v);
34
+ },
35
+ del(k) {
36
+ map.delete(k);
37
+ },
38
+ };
39
+ }
40
+ /** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
41
+ * Gracefully falls back to memory storage when running in an environment
42
+ * without Web Storage (SSR, worker without storage, etc). */
43
+ export function resolveSessionStorage(type = "local") {
44
+ if (typeof type === "object" && type !== null)
45
+ return type;
46
+ if (type === "memory")
47
+ return createMemorySessionStorage();
48
+ const backend = type === "session"
49
+ ? "sessionStorage"
50
+ : "localStorage";
51
+ const g = globalThis;
52
+ const store = g[backend];
53
+ if (!store)
54
+ return createMemorySessionStorage();
55
+ return {
56
+ get(k) {
57
+ try {
58
+ return store.getItem(k);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ },
64
+ set(k, v) {
65
+ try {
66
+ store.setItem(k, v);
67
+ }
68
+ catch {
69
+ // quota exceeded / disabled — degrade silently
70
+ }
71
+ },
72
+ del(k) {
73
+ try {
74
+ store.removeItem(k);
75
+ }
76
+ catch {
77
+ // ignore
78
+ }
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * Session manager — pure reactive state + persistence, no HTTP.
84
+ *
85
+ * Writes are driven by the AuthManager. On construction it hydrates from
86
+ * storage; if the stored JWT has expired, it transitions to anonymous and
87
+ * clears the storage.
88
+ */
89
+ export class SessionManager {
90
+ #store;
91
+ #pubsub;
92
+ #storage;
93
+ #storageKey;
94
+ constructor(options = {}) {
95
+ this.#pubsub = options.pubsub ?? createPubSub();
96
+ this.#storage = resolveSessionStorage(options.storage ?? "local");
97
+ this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
98
+ this.#store = createStore({ ...EMPTY });
99
+ this.#hydrate();
100
+ }
101
+ /** Read from storage and populate the store. Expired sessions are wiped. */
102
+ #hydrate() {
103
+ const raw = this.#storage.get(this.#storageKey);
104
+ if (!raw)
105
+ return;
106
+ try {
107
+ const parsed = JSON.parse(raw);
108
+ // Basic shape check.
109
+ if (typeof parsed !== "object" ||
110
+ parsed === null ||
111
+ typeof parsed.status !== "string") {
112
+ this.#storage.del(this.#storageKey);
113
+ return;
114
+ }
115
+ // Expiry check (only meaningful when expiresAt is set).
116
+ if (parsed.expiresAt !== null &&
117
+ parsed.expiresAt !== undefined &&
118
+ parsed.expiresAt * 1000 <= Date.now()) {
119
+ this.#storage.del(this.#storageKey);
120
+ return;
121
+ }
122
+ this.#store.set(parsed);
123
+ }
124
+ catch {
125
+ this.#storage.del(this.#storageKey);
126
+ }
127
+ }
128
+ #persist() {
129
+ const s = this.#store.get();
130
+ if (s.status === "anonymous") {
131
+ this.#storage.del(this.#storageKey);
132
+ }
133
+ else {
134
+ this.#storage.set(this.#storageKey, JSON.stringify(s));
135
+ }
136
+ }
137
+ #emitChange() {
138
+ this.#pubsub.publish("auth:session:changed", {
139
+ type: "auth:session:changed",
140
+ timestamp: Date.now(),
141
+ session: this.#store.get(),
142
+ });
143
+ }
144
+ /** Svelte-compatible subscribe. */
145
+ get subscribe() {
146
+ return this.#store.subscribe;
147
+ }
148
+ /** Current session state snapshot. */
149
+ get() {
150
+ return this.#store.get();
151
+ }
152
+ get isAuthenticated() {
153
+ return this.#store.get().status === "authenticated";
154
+ }
155
+ get isUnverified() {
156
+ return this.#store.get().status === "unverified";
157
+ }
158
+ get isAnonymous() {
159
+ return this.#store.get().status === "anonymous";
160
+ }
161
+ /** JWT for adapter `Authorization` headers, or null when anonymous. */
162
+ getJwt() {
163
+ return this.#store.get().jwt;
164
+ }
165
+ /** Transition to authenticated. Called after login/register/OAuth succeed
166
+ * and the subject has been loaded. */
167
+ setAuthenticated(opts) {
168
+ const { jwt, subject, expiresAt = null } = opts;
169
+ this.#store.set({
170
+ status: "authenticated",
171
+ subject,
172
+ jwt,
173
+ expiresAt,
174
+ });
175
+ this.#persist();
176
+ this.#emitChange();
177
+ }
178
+ /** Server confirmed credentials but refuses login until email is
179
+ * verified. Expose the email so the UI can prompt "check your inbox"
180
+ * without a second server call. No JWT in this state. */
181
+ setUnverified(email) {
182
+ this.#store.set({
183
+ status: "unverified",
184
+ subject: {
185
+ id: "",
186
+ email,
187
+ roles: [],
188
+ isVerified: false,
189
+ hasPassword: false,
190
+ },
191
+ jwt: null,
192
+ expiresAt: null,
193
+ });
194
+ this.#persist();
195
+ this.#emitChange();
196
+ }
197
+ /** Drop the session. Storage is cleared; downstream domains should be
198
+ * reset by the suite orchestrator. */
199
+ clear() {
200
+ if (this.#store.get().status === "anonymous")
201
+ return;
202
+ this.#store.set({ ...EMPTY });
203
+ this.#persist();
204
+ this.#emitChange();
205
+ }
206
+ /** Patch the subject in place without touching the JWT. Used when the
207
+ * profile manager changes email / linked providers / etc. */
208
+ patchSubject(patch) {
209
+ const s = this.#store.get();
210
+ if (!s.subject)
211
+ return;
212
+ const next = {
213
+ ...s,
214
+ subject: { ...s.subject, ...patch },
215
+ };
216
+ this.#store.set(next);
217
+ this.#persist();
218
+ this.#emitChange();
219
+ }
220
+ /** Test / reset hook — clears storage AND in-memory state without
221
+ * emitting. Used by teardown. */
222
+ destroy() {
223
+ this.#storage.del(this.#storageKey);
224
+ this.#store.set({ ...EMPTY });
225
+ }
226
+ }
package/dist/mod.d.ts CHANGED
@@ -29,3 +29,4 @@ export * from "./ownsuite.js";
29
29
  export * from "./domains/mod.js";
30
30
  export * from "./types/mod.js";
31
31
  export * from "./adapters/mod.js";
32
+ export * from "./oauth/popup.js";
package/dist/mod.js CHANGED
@@ -29,3 +29,4 @@ export * from "./ownsuite.js";
29
29
  export * from "./domains/mod.js";
30
30
  export * from "./types/mod.js";
31
31
  export * from "./adapters/mod.js";
32
+ export * from "./oauth/popup.js";
@@ -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
+ }
@@ -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;