@marianmeres/ownsuite 2.1.0 → 2.2.2
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 +11 -4
- package/API.md +27 -6
- package/README.md +5 -2
- package/dist/adapters/mock-auth.d.ts +24 -2
- package/dist/adapters/mock-auth.js +8 -0
- package/dist/adapters/mock.d.ts +2 -0
- package/dist/adapters/stack-account.d.ts +7 -0
- package/dist/adapters/stack-account.js +6 -0
- package/dist/domains/auth.d.ts +49 -5
- package/dist/domains/auth.js +59 -10
- package/dist/domains/base.d.ts +20 -0
- package/dist/domains/base.js +18 -0
- package/dist/domains/owned-collection.d.ts +5 -0
- package/dist/domains/owned-collection.js +2 -0
- package/dist/domains/profile.d.ts +18 -0
- package/dist/domains/profile.js +11 -0
- package/dist/domains/session.d.ts +35 -7
- package/dist/domains/session.js +131 -26
- package/dist/mod.d.ts +3 -0
- package/dist/oauth/popup.d.ts +25 -0
- package/dist/ownsuite.d.ts +13 -2
- package/dist/ownsuite.js +10 -2
- package/dist/types/adapter.d.ts +4 -0
- package/dist/types/auth.d.ts +88 -0
- package/dist/types/events.d.ts +59 -8
- package/package.json +1 -1
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
import type { OwnedCollectionAdapter } from "../types/adapter.js";
|
|
17
17
|
import type { OwnedCollectionState } from "../types/state.js";
|
|
18
18
|
import { BaseDomainManager, type BaseDomainOptions } from "./base.js";
|
|
19
|
+
/** Construction-time options for {@link OwnedCollectionManager}. */
|
|
19
20
|
export interface OwnedCollectionManagerOptions<TRow, TCreate, TUpdate> extends BaseDomainOptions {
|
|
21
|
+
/** Server adapter for this domain. Optional at construction — can be
|
|
22
|
+
* installed later with `setAdapter()`. */
|
|
20
23
|
adapter?: OwnedCollectionAdapter<TRow, TCreate, TUpdate>;
|
|
21
24
|
/** Function that extracts the row id from a row. Defaults to `row.model_id`. */
|
|
22
25
|
getRowId?: (row: TRow) => string;
|
|
@@ -30,6 +33,8 @@ export interface OwnedCollectionManagerOptions<TRow, TCreate, TUpdate> extends B
|
|
|
30
33
|
*/
|
|
31
34
|
export declare class OwnedCollectionManager<TRow = Record<string, unknown>, TCreate = Partial<TRow>, TUpdate = Partial<TRow>> extends BaseDomainManager<OwnedCollectionState<TRow>, OwnedCollectionAdapter<TRow, TCreate, TUpdate>> {
|
|
32
35
|
#private;
|
|
36
|
+
/** Build a new manager. Normally called by `Ownsuite.registerDomain`,
|
|
37
|
+
* not consumers. */
|
|
33
38
|
constructor(domainName: string, options?: OwnedCollectionManagerOptions<TRow, TCreate, TUpdate>);
|
|
34
39
|
/** Initialize by fetching the list from the server. */
|
|
35
40
|
initialize(): Promise<void>;
|
|
@@ -32,6 +32,8 @@ const defaultGetRowId = (row) => {
|
|
|
32
32
|
*/
|
|
33
33
|
export class OwnedCollectionManager extends BaseDomainManager {
|
|
34
34
|
#getRowId;
|
|
35
|
+
/** Build a new manager. Normally called by `Ownsuite.registerDomain`,
|
|
36
|
+
* not consumers. */
|
|
35
37
|
constructor(domainName, options = {}) {
|
|
36
38
|
super(domainName, options);
|
|
37
39
|
this.#getRowId = options.getRowId ?? defaultGetRowId;
|
|
@@ -20,8 +20,14 @@ import { type StoreLike } from "@marianmeres/store";
|
|
|
20
20
|
import { type PubSub } from "@marianmeres/pubsub";
|
|
21
21
|
import type { OAuthConnection, OAuthProvider, OwnsuiteContext, ProfileAdapter, ProfileResult } from "../types/mod.js";
|
|
22
22
|
import type { SessionManager } from "./session.js";
|
|
23
|
+
/** Construction-time options for {@link ProfileManager}. Assembled by
|
|
24
|
+
* `createOwnsuite` when a profile adapter is supplied. */
|
|
23
25
|
export interface ProfileManagerOptions {
|
|
26
|
+
/** Profile adapter — talks to `/me`. */
|
|
24
27
|
adapter: ProfileAdapter;
|
|
28
|
+
/** Session manager — used to patch the subject in place on fetch /
|
|
29
|
+
* update so consumers reading the session see updates without a second
|
|
30
|
+
* subscription. */
|
|
25
31
|
session: SessionManager;
|
|
26
32
|
/** Shared pubsub for event emission. Private if omitted. */
|
|
27
33
|
pubsub?: PubSub;
|
|
@@ -29,6 +35,7 @@ export interface ProfileManagerOptions {
|
|
|
29
35
|
* `jwt` and `signal` by the manager. */
|
|
30
36
|
context?: OwnsuiteContext;
|
|
31
37
|
}
|
|
38
|
+
/** Reactive state exposed by {@link ProfileManager.get} / `subscribe`. */
|
|
32
39
|
export interface ProfileState {
|
|
33
40
|
/** Null until the first successful fetch. */
|
|
34
41
|
profile: ProfileResult | null;
|
|
@@ -42,10 +49,15 @@ export interface ProfileState {
|
|
|
42
49
|
*/
|
|
43
50
|
export declare class ProfileManager {
|
|
44
51
|
#private;
|
|
52
|
+
/** Build a new profile manager. Normally called by `createOwnsuite`. */
|
|
45
53
|
constructor(options: ProfileManagerOptions);
|
|
54
|
+
/** Svelte-compatible subscribe method over {@link ProfileState}. */
|
|
46
55
|
get subscribe(): StoreLike<ProfileState>["subscribe"];
|
|
56
|
+
/** Current profile state snapshot. */
|
|
47
57
|
get(): ProfileState;
|
|
58
|
+
/** Merge `ctx` into the adapter context (keys not present are kept). */
|
|
48
59
|
setContext(ctx: OwnsuiteContext): void;
|
|
60
|
+
/** Replace the adapter context wholesale. */
|
|
49
61
|
replaceContext(ctx: OwnsuiteContext): void;
|
|
50
62
|
/** Fetch `/me`. Supersedes any in-flight fetch. */
|
|
51
63
|
fetch(): Promise<ProfileResult>;
|
|
@@ -55,8 +67,14 @@ export declare class ProfileManager {
|
|
|
55
67
|
email?: string;
|
|
56
68
|
current_password?: string;
|
|
57
69
|
}): Promise<ProfileResult>;
|
|
70
|
+
/** List OAuth provider connections linked to the authenticated account. */
|
|
58
71
|
listOAuth(): Promise<OAuthConnection[]>;
|
|
72
|
+
/** Unlink an OAuth provider. Emits `oauth:unlinked` and best-effort
|
|
73
|
+
* re-fetches the profile so the connection list reflects the change. */
|
|
59
74
|
unlinkOAuth(provider: OAuthProvider): Promise<void>;
|
|
75
|
+
/** Abort any in-flight fetch and clear cached profile state. */
|
|
60
76
|
reset(): void;
|
|
77
|
+
/** Tear down the manager — aborts in-flight fetches and clears state.
|
|
78
|
+
* Called by `Ownsuite.destroy()`. */
|
|
61
79
|
destroy(): void;
|
|
62
80
|
}
|
package/dist/domains/profile.js
CHANGED
|
@@ -34,6 +34,7 @@ export class ProfileManager {
|
|
|
34
34
|
#context;
|
|
35
35
|
/** Currently-active read controller, for abort-supersede semantics. */
|
|
36
36
|
#readController = null;
|
|
37
|
+
/** Build a new profile manager. Normally called by `createOwnsuite`. */
|
|
37
38
|
constructor(options) {
|
|
38
39
|
this.#adapter = options.adapter;
|
|
39
40
|
this.#session = options.session;
|
|
@@ -41,15 +42,19 @@ export class ProfileManager {
|
|
|
41
42
|
this.#context = options.context ?? {};
|
|
42
43
|
this.#store = createStore({ ...EMPTY });
|
|
43
44
|
}
|
|
45
|
+
/** Svelte-compatible subscribe method over {@link ProfileState}. */
|
|
44
46
|
get subscribe() {
|
|
45
47
|
return this.#store.subscribe;
|
|
46
48
|
}
|
|
49
|
+
/** Current profile state snapshot. */
|
|
47
50
|
get() {
|
|
48
51
|
return this.#store.get();
|
|
49
52
|
}
|
|
53
|
+
/** Merge `ctx` into the adapter context (keys not present are kept). */
|
|
50
54
|
setContext(ctx) {
|
|
51
55
|
this.#context = { ...this.#context, ...ctx };
|
|
52
56
|
}
|
|
57
|
+
/** Replace the adapter context wholesale. */
|
|
53
58
|
replaceContext(ctx) {
|
|
54
59
|
this.#context = { ...ctx };
|
|
55
60
|
}
|
|
@@ -139,10 +144,13 @@ export class ProfileManager {
|
|
|
139
144
|
throw e;
|
|
140
145
|
}
|
|
141
146
|
}
|
|
147
|
+
/** List OAuth provider connections linked to the authenticated account. */
|
|
142
148
|
async listOAuth() {
|
|
143
149
|
const ctrl = new AbortController();
|
|
144
150
|
return await this.#adapter.listOAuth(this.#ctxFor(ctrl.signal));
|
|
145
151
|
}
|
|
152
|
+
/** Unlink an OAuth provider. Emits `oauth:unlinked` and best-effort
|
|
153
|
+
* re-fetches the profile so the connection list reflects the change. */
|
|
146
154
|
async unlinkOAuth(provider) {
|
|
147
155
|
const ctrl = new AbortController();
|
|
148
156
|
await this.#adapter.unlinkOAuth(provider, this.#ctxFor(ctrl.signal));
|
|
@@ -159,10 +167,13 @@ export class ProfileManager {
|
|
|
159
167
|
// swallow — caller already got a successful unlink
|
|
160
168
|
}
|
|
161
169
|
}
|
|
170
|
+
/** Abort any in-flight fetch and clear cached profile state. */
|
|
162
171
|
reset() {
|
|
163
172
|
this.#abortActiveRead("reset");
|
|
164
173
|
this.#store.set({ ...EMPTY });
|
|
165
174
|
}
|
|
175
|
+
/** Tear down the manager — aborts in-flight fetches and clears state.
|
|
176
|
+
* Called by `Ownsuite.destroy()`. */
|
|
166
177
|
destroy() {
|
|
167
178
|
this.#abortActiveRead("destroyed");
|
|
168
179
|
this.#store.set({ ...EMPTY });
|
|
@@ -22,6 +22,7 @@ export declare function createMemorySessionStorage(): SessionStorage;
|
|
|
22
22
|
* Gracefully falls back to memory storage when running in an environment
|
|
23
23
|
* without Web Storage (SSR, worker without storage, etc). */
|
|
24
24
|
export declare function resolveSessionStorage(type?: SessionStorageType): SessionStorage;
|
|
25
|
+
/** Construction-time options for {@link SessionManager}. */
|
|
25
26
|
export interface SessionManagerOptions {
|
|
26
27
|
/** Storage backend for session persistence. Default: "local". */
|
|
27
28
|
storage?: SessionStorageType;
|
|
@@ -34,40 +35,67 @@ export interface SessionManagerOptions {
|
|
|
34
35
|
/**
|
|
35
36
|
* Session manager — pure reactive state + persistence, no HTTP.
|
|
36
37
|
*
|
|
37
|
-
* Writes are driven by the AuthManager. On construction it hydrates
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Writes are driven by the AuthManager. On construction it hydrates by
|
|
39
|
+
* probing the built-in backends in order `local → session → memory` and
|
|
40
|
+
* adopting whichever holds a non-expired payload as the active backend for
|
|
41
|
+
* the rest of the instance's lifetime (until `clear()`). Stale blobs on the
|
|
42
|
+
* other built-in backends are wiped on adoption.
|
|
43
|
+
*
|
|
44
|
+
* When constructed with a custom `SessionStorage` object, there is a single
|
|
45
|
+
* backend and per-login `remember` choices are silently ignored.
|
|
40
46
|
*/
|
|
41
47
|
export declare class SessionManager {
|
|
42
48
|
#private;
|
|
49
|
+
/** Build a new session manager. Hydrates synchronously from storage
|
|
50
|
+
* (probing `local → session → memory` in the built-in case) before the
|
|
51
|
+
* constructor returns, so consumers can read `get()` immediately. */
|
|
43
52
|
constructor(options?: SessionManagerOptions);
|
|
44
53
|
/** Svelte-compatible subscribe. */
|
|
45
54
|
get subscribe(): StoreLike<SessionState>["subscribe"];
|
|
46
55
|
/** Current session state snapshot. */
|
|
47
56
|
get(): SessionState;
|
|
57
|
+
/** True when `status === "authenticated"`. Shorthand for `get().status`
|
|
58
|
+
* checks in consumer code. */
|
|
48
59
|
get isAuthenticated(): boolean;
|
|
60
|
+
/** True when `status === "unverified"` — account exists but the server
|
|
61
|
+
* gates login behind email verification. */
|
|
49
62
|
get isUnverified(): boolean;
|
|
63
|
+
/** True when `status === "anonymous"` — no session. */
|
|
50
64
|
get isAnonymous(): boolean;
|
|
51
65
|
/** JWT for adapter `Authorization` headers, or null when anonymous. */
|
|
52
66
|
getJwt(): string | null;
|
|
53
67
|
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
54
|
-
* and the subject has been loaded.
|
|
68
|
+
* and the subject has been loaded.
|
|
69
|
+
*
|
|
70
|
+
* When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
|
|
71
|
+
* pins this session to that built-in backend; subsequent
|
|
72
|
+
* `patchSubject` / `setUnverified` writes land on the same backend.
|
|
73
|
+
* The previously-active backend's blob is wiped as part of the switch
|
|
74
|
+
* so "Remember me" toggles don't leave stale data behind.
|
|
75
|
+
*
|
|
76
|
+
* Ignored when the manager was constructed with a custom `SessionStorage`
|
|
77
|
+
* object (single backend) or when `opts.storage` is itself an object
|
|
78
|
+
* (no multi-backend custom storage by design). */
|
|
55
79
|
setAuthenticated(opts: {
|
|
56
80
|
jwt: string;
|
|
57
81
|
subject: SessionSubject;
|
|
58
82
|
expiresAt?: number | null;
|
|
83
|
+
storage?: SessionStorageType;
|
|
59
84
|
}): void;
|
|
60
85
|
/** Server confirmed credentials but refuses login until email is
|
|
61
86
|
* verified. Expose the email so the UI can prompt "check your inbox"
|
|
62
87
|
* without a second server call. No JWT in this state. */
|
|
63
88
|
setUnverified(email: string): void;
|
|
64
|
-
/** Drop the session.
|
|
65
|
-
*
|
|
89
|
+
/** Drop the session. Every built-in backend (local + session + memory)
|
|
90
|
+
* is wiped — not just the active one — so stale blobs from a previous
|
|
91
|
+
* "Remember me" toggle can't leak back in on the next construction.
|
|
92
|
+
* Resets the active backend to the manager's configured default.
|
|
93
|
+
* Downstream domains should be reset by the suite orchestrator. */
|
|
66
94
|
clear(): void;
|
|
67
95
|
/** Patch the subject in place without touching the JWT. Used when the
|
|
68
96
|
* profile manager changes email / linked providers / etc. */
|
|
69
97
|
patchSubject(patch: Partial<SessionSubject>): void;
|
|
70
|
-
/** Test / reset hook — clears
|
|
98
|
+
/** Test / reset hook — clears every backend AND in-memory state without
|
|
71
99
|
* emitting. Used by teardown. */
|
|
72
100
|
destroy(): void;
|
|
73
101
|
}
|
package/dist/domains/session.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
import { createStore } from "@marianmeres/store";
|
|
17
17
|
import { createPubSub } from "@marianmeres/pubsub";
|
|
18
18
|
const DEFAULT_STORAGE_KEY = "ownsuite:session";
|
|
19
|
+
const BUILT_IN_ORDER = [
|
|
20
|
+
"local",
|
|
21
|
+
"session",
|
|
22
|
+
"memory",
|
|
23
|
+
];
|
|
19
24
|
const EMPTY = {
|
|
20
25
|
status: "anonymous",
|
|
21
26
|
subject: null,
|
|
@@ -82,56 +87,117 @@ export function resolveSessionStorage(type = "local") {
|
|
|
82
87
|
/**
|
|
83
88
|
* Session manager — pure reactive state + persistence, no HTTP.
|
|
84
89
|
*
|
|
85
|
-
* Writes are driven by the AuthManager. On construction it hydrates
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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.
|
|
88
98
|
*/
|
|
89
99
|
export class SessionManager {
|
|
90
100
|
#store;
|
|
91
101
|
#pubsub;
|
|
92
|
-
#storage;
|
|
93
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
|
+
/** Build a new session manager. Hydrates synchronously from storage
|
|
113
|
+
* (probing `local → session → memory` in the built-in case) before the
|
|
114
|
+
* constructor returns, so consumers can read `get()` immediately. */
|
|
94
115
|
constructor(options = {}) {
|
|
95
116
|
this.#pubsub = options.pubsub ?? createPubSub();
|
|
96
|
-
this.#storage = resolveSessionStorage(options.storage ?? "local");
|
|
97
117
|
this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
98
118
|
this.#store = createStore({ ...EMPTY });
|
|
119
|
+
const configured = options.storage ?? "local";
|
|
120
|
+
if (typeof configured === "string") {
|
|
121
|
+
this.#customStorage = null;
|
|
122
|
+
this.#builtIn = {
|
|
123
|
+
local: resolveSessionStorage("local"),
|
|
124
|
+
session: resolveSessionStorage("session"),
|
|
125
|
+
memory: createMemorySessionStorage(),
|
|
126
|
+
};
|
|
127
|
+
this.#defaultStorageType = configured;
|
|
128
|
+
this.#activeStorage = this.#builtIn[configured];
|
|
129
|
+
this.#activeStorageType = configured;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.#customStorage = configured;
|
|
133
|
+
this.#builtIn = null;
|
|
134
|
+
this.#defaultStorageType = "local";
|
|
135
|
+
this.#activeStorage = configured;
|
|
136
|
+
this.#activeStorageType = "custom";
|
|
137
|
+
}
|
|
99
138
|
this.#hydrate();
|
|
100
139
|
}
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
140
|
+
/** Try to parse a payload and validate shape + expiry. Returns the state
|
|
141
|
+
* on success, or `null` (and deletes the stored blob) on any failure. */
|
|
142
|
+
#readCandidate(storage) {
|
|
143
|
+
const raw = storage.get(this.#storageKey);
|
|
104
144
|
if (!raw)
|
|
105
|
-
return;
|
|
145
|
+
return null;
|
|
106
146
|
try {
|
|
107
147
|
const parsed = JSON.parse(raw);
|
|
108
|
-
// Basic shape check.
|
|
109
148
|
if (typeof parsed !== "object" ||
|
|
110
149
|
parsed === null ||
|
|
111
150
|
typeof parsed.status !== "string") {
|
|
112
|
-
|
|
113
|
-
return;
|
|
151
|
+
storage.del(this.#storageKey);
|
|
152
|
+
return null;
|
|
114
153
|
}
|
|
115
|
-
// Expiry check (only meaningful when expiresAt is set).
|
|
116
154
|
if (parsed.expiresAt !== null &&
|
|
117
155
|
parsed.expiresAt !== undefined &&
|
|
118
156
|
parsed.expiresAt * 1000 <= Date.now()) {
|
|
119
|
-
|
|
120
|
-
return;
|
|
157
|
+
storage.del(this.#storageKey);
|
|
158
|
+
return null;
|
|
121
159
|
}
|
|
122
|
-
|
|
160
|
+
return parsed;
|
|
123
161
|
}
|
|
124
162
|
catch {
|
|
125
|
-
|
|
163
|
+
storage.del(this.#storageKey);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Read from storage and populate the store. Expired sessions are wiped.
|
|
168
|
+
* In the built-in case, probes `local → session → memory` and wipes
|
|
169
|
+
* the losing backends so stale blobs can't leak back in. */
|
|
170
|
+
#hydrate() {
|
|
171
|
+
if (this.#customStorage) {
|
|
172
|
+
const parsed = this.#readCandidate(this.#customStorage);
|
|
173
|
+
if (parsed)
|
|
174
|
+
this.#store.set(parsed);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const builtIn = this.#builtIn;
|
|
178
|
+
for (const type of BUILT_IN_ORDER) {
|
|
179
|
+
const parsed = this.#readCandidate(builtIn[type]);
|
|
180
|
+
if (!parsed)
|
|
181
|
+
continue;
|
|
182
|
+
// Adopt this backend; wipe the others so a later login-with-toggle
|
|
183
|
+
// can't accidentally re-hydrate a stale blob.
|
|
184
|
+
for (const other of BUILT_IN_ORDER) {
|
|
185
|
+
if (other !== type)
|
|
186
|
+
builtIn[other].del(this.#storageKey);
|
|
187
|
+
}
|
|
188
|
+
this.#activeStorage = builtIn[type];
|
|
189
|
+
this.#activeStorageType = type;
|
|
190
|
+
this.#store.set(parsed);
|
|
191
|
+
return;
|
|
126
192
|
}
|
|
127
193
|
}
|
|
128
194
|
#persist() {
|
|
129
195
|
const s = this.#store.get();
|
|
130
196
|
if (s.status === "anonymous") {
|
|
131
|
-
this.#
|
|
197
|
+
this.#activeStorage.del(this.#storageKey);
|
|
132
198
|
}
|
|
133
199
|
else {
|
|
134
|
-
this.#
|
|
200
|
+
this.#activeStorage.set(this.#storageKey, JSON.stringify(s));
|
|
135
201
|
}
|
|
136
202
|
}
|
|
137
203
|
#emitChange() {
|
|
@@ -149,12 +215,17 @@ export class SessionManager {
|
|
|
149
215
|
get() {
|
|
150
216
|
return this.#store.get();
|
|
151
217
|
}
|
|
218
|
+
/** True when `status === "authenticated"`. Shorthand for `get().status`
|
|
219
|
+
* checks in consumer code. */
|
|
152
220
|
get isAuthenticated() {
|
|
153
221
|
return this.#store.get().status === "authenticated";
|
|
154
222
|
}
|
|
223
|
+
/** True when `status === "unverified"` — account exists but the server
|
|
224
|
+
* gates login behind email verification. */
|
|
155
225
|
get isUnverified() {
|
|
156
226
|
return this.#store.get().status === "unverified";
|
|
157
227
|
}
|
|
228
|
+
/** True when `status === "anonymous"` — no session. */
|
|
158
229
|
get isAnonymous() {
|
|
159
230
|
return this.#store.get().status === "anonymous";
|
|
160
231
|
}
|
|
@@ -163,9 +234,26 @@ export class SessionManager {
|
|
|
163
234
|
return this.#store.get().jwt;
|
|
164
235
|
}
|
|
165
236
|
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
166
|
-
* and the subject has been loaded.
|
|
237
|
+
* and the subject has been loaded.
|
|
238
|
+
*
|
|
239
|
+
* When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
|
|
240
|
+
* pins this session to that built-in backend; subsequent
|
|
241
|
+
* `patchSubject` / `setUnverified` writes land on the same backend.
|
|
242
|
+
* The previously-active backend's blob is wiped as part of the switch
|
|
243
|
+
* so "Remember me" toggles don't leave stale data behind.
|
|
244
|
+
*
|
|
245
|
+
* Ignored when the manager was constructed with a custom `SessionStorage`
|
|
246
|
+
* object (single backend) or when `opts.storage` is itself an object
|
|
247
|
+
* (no multi-backend custom storage by design). */
|
|
167
248
|
setAuthenticated(opts) {
|
|
168
|
-
const { jwt, subject, expiresAt = null } = opts;
|
|
249
|
+
const { jwt, subject, expiresAt = null, storage } = opts;
|
|
250
|
+
if (typeof storage === "string" && this.#builtIn) {
|
|
251
|
+
if (this.#activeStorage !== this.#builtIn[storage]) {
|
|
252
|
+
this.#activeStorage.del(this.#storageKey);
|
|
253
|
+
this.#activeStorage = this.#builtIn[storage];
|
|
254
|
+
this.#activeStorageType = storage;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
169
257
|
this.#store.set({
|
|
170
258
|
status: "authenticated",
|
|
171
259
|
subject,
|
|
@@ -194,15 +282,32 @@ export class SessionManager {
|
|
|
194
282
|
this.#persist();
|
|
195
283
|
this.#emitChange();
|
|
196
284
|
}
|
|
197
|
-
/** Drop the session.
|
|
198
|
-
*
|
|
285
|
+
/** Drop the session. Every built-in backend (local + session + memory)
|
|
286
|
+
* is wiped — not just the active one — so stale blobs from a previous
|
|
287
|
+
* "Remember me" toggle can't leak back in on the next construction.
|
|
288
|
+
* Resets the active backend to the manager's configured default.
|
|
289
|
+
* Downstream domains should be reset by the suite orchestrator. */
|
|
199
290
|
clear() {
|
|
291
|
+
this.#wipeAllBackends();
|
|
292
|
+
if (this.#builtIn) {
|
|
293
|
+
this.#activeStorage = this.#builtIn[this.#defaultStorageType];
|
|
294
|
+
this.#activeStorageType = this.#defaultStorageType;
|
|
295
|
+
}
|
|
200
296
|
if (this.#store.get().status === "anonymous")
|
|
201
297
|
return;
|
|
202
298
|
this.#store.set({ ...EMPTY });
|
|
203
|
-
this.#persist();
|
|
204
299
|
this.#emitChange();
|
|
205
300
|
}
|
|
301
|
+
#wipeAllBackends() {
|
|
302
|
+
if (this.#customStorage) {
|
|
303
|
+
this.#customStorage.del(this.#storageKey);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const builtIn = this.#builtIn;
|
|
307
|
+
for (const type of BUILT_IN_ORDER) {
|
|
308
|
+
builtIn[type].del(this.#storageKey);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
206
311
|
/** Patch the subject in place without touching the JWT. Used when the
|
|
207
312
|
* profile manager changes email / linked providers / etc. */
|
|
208
313
|
patchSubject(patch) {
|
|
@@ -217,10 +322,10 @@ export class SessionManager {
|
|
|
217
322
|
this.#persist();
|
|
218
323
|
this.#emitChange();
|
|
219
324
|
}
|
|
220
|
-
/** Test / reset hook — clears
|
|
325
|
+
/** Test / reset hook — clears every backend AND in-memory state without
|
|
221
326
|
* emitting. Used by teardown. */
|
|
222
327
|
destroy() {
|
|
223
|
-
this.#
|
|
328
|
+
this.#wipeAllBackends();
|
|
224
329
|
this.#store.set({ ...EMPTY });
|
|
225
330
|
}
|
|
226
331
|
}
|
package/dist/mod.d.ts
CHANGED
|
@@ -30,3 +30,6 @@ export * from "./domains/mod.js";
|
|
|
30
30
|
export * from "./types/mod.js";
|
|
31
31
|
export * from "./adapters/mod.js";
|
|
32
32
|
export * from "./oauth/popup.js";
|
|
33
|
+
export type { PubSub, Subscriber, Unsubscriber } from "@marianmeres/pubsub";
|
|
34
|
+
export type { StoreLike } from "@marianmeres/store";
|
|
35
|
+
export type { Clog } from "@marianmeres/clog";
|
package/dist/oauth/popup.d.ts
CHANGED
|
@@ -11,37 +11,62 @@
|
|
|
11
11
|
* For tests, an injectable window-like interface lets us shim `postMessage`
|
|
12
12
|
* via a MessageChannel without a real browser.
|
|
13
13
|
*/
|
|
14
|
+
/** `postMessage` shape the server's OAuth callback posts on successful
|
|
15
|
+
* login (`action: "login"`). */
|
|
14
16
|
export interface OAuthPopupLoginMessage {
|
|
17
|
+
/** Discriminator. */
|
|
15
18
|
type: "oauth_login_success";
|
|
19
|
+
/** Freshly minted JWT. */
|
|
16
20
|
jwt: string;
|
|
21
|
+
/** Email on the account (after provider-email resolution). */
|
|
17
22
|
email: string;
|
|
23
|
+
/** Roles assigned to the account. */
|
|
18
24
|
roles?: string[];
|
|
25
|
+
/** True when the account was created as part of this flow. */
|
|
19
26
|
isNewAccount?: boolean;
|
|
27
|
+
/** Optional post-login redirect hint the server proposes. */
|
|
20
28
|
redirectUrl?: string;
|
|
21
29
|
}
|
|
30
|
+
/** `postMessage` shape the server posts on successful link
|
|
31
|
+
* (`action: "link"`). */
|
|
22
32
|
export interface OAuthPopupLinkMessage {
|
|
33
|
+
/** Discriminator. */
|
|
23
34
|
type: "oauth_link_success";
|
|
35
|
+
/** Which provider was just linked. */
|
|
24
36
|
provider: string;
|
|
25
37
|
}
|
|
38
|
+
/** `postMessage` shape the server posts on error. Rejects the promise. */
|
|
26
39
|
export interface OAuthPopupErrorMessage {
|
|
40
|
+
/** Discriminator. */
|
|
27
41
|
type: "oauth_error";
|
|
42
|
+
/** Error string suitable for surfacing to the user. */
|
|
28
43
|
error: string;
|
|
29
44
|
}
|
|
45
|
+
/** Union returned by {@link openOAuthPopup}. */
|
|
30
46
|
export type OAuthPopupMessage = OAuthPopupLoginMessage | OAuthPopupLinkMessage;
|
|
31
47
|
/**
|
|
32
48
|
* Minimal window-like surface we need. Real `Window` satisfies this; tests
|
|
33
49
|
* supply a shim.
|
|
34
50
|
*/
|
|
35
51
|
export interface PopupWindowHost {
|
|
52
|
+
/** Open a popup and return a handle, or `null` when blocked. */
|
|
36
53
|
open(url: string, target: string, features?: string): PopupWindowHandle | null;
|
|
54
|
+
/** Listen for `"message"` events emitted by the popup. */
|
|
37
55
|
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
56
|
+
/** Stop listening for `"message"` events. */
|
|
38
57
|
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
39
58
|
}
|
|
59
|
+
/** Minimal surface of a popup window handle that {@link openOAuthPopup}
|
|
60
|
+
* interacts with after `host.open(...)`. */
|
|
40
61
|
export interface PopupWindowHandle {
|
|
62
|
+
/** Reflects whether the popup has been closed (by the user or server). */
|
|
41
63
|
closed: boolean;
|
|
64
|
+
/** Bring the popup to the front, when the host supports it. */
|
|
42
65
|
focus?(): void;
|
|
66
|
+
/** Close the popup programmatically. */
|
|
43
67
|
close?(): void;
|
|
44
68
|
}
|
|
69
|
+
/** Options for {@link openOAuthPopup}. */
|
|
45
70
|
export interface OpenOAuthPopupOptions {
|
|
46
71
|
/** Host window — defaults to `globalThis` when running in the browser. */
|
|
47
72
|
host?: PopupWindowHost;
|
package/dist/ownsuite.d.ts
CHANGED
|
@@ -25,7 +25,10 @@ import { SessionManager } from "./domains/session.js";
|
|
|
25
25
|
* (defaults to reading `row.model_id` or `row.id`).
|
|
26
26
|
*/
|
|
27
27
|
export interface OwnsuiteDomainConfig<TRow = any, TCreate = any, TUpdate = any> {
|
|
28
|
+
/** Adapter that talks to the server for this domain. */
|
|
28
29
|
adapter: OwnedCollectionAdapter<TRow, TCreate, TUpdate>;
|
|
30
|
+
/** Row-id extractor — used for optimistic update / delete matching.
|
|
31
|
+
* Defaults to reading `row.model_id` or `row.id`. */
|
|
29
32
|
getRowId?: (row: TRow) => string;
|
|
30
33
|
}
|
|
31
34
|
/** Top-level ownsuite configuration. */
|
|
@@ -76,11 +79,18 @@ export interface SetContextOptions {
|
|
|
76
79
|
*/
|
|
77
80
|
export declare class Ownsuite {
|
|
78
81
|
#private;
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
82
|
+
/** Session manager. Present iff `adapters.auth` was supplied at
|
|
83
|
+
* construction; otherwise `null`. */
|
|
81
84
|
readonly session: SessionManager | null;
|
|
85
|
+
/** Auth manager (register/login/OAuth verbs). Present iff
|
|
86
|
+
* `adapters.auth` was supplied; otherwise `null`. */
|
|
82
87
|
readonly auth: AuthManager | null;
|
|
88
|
+
/** Profile manager for `/me`. Present iff BOTH `adapters.auth` and
|
|
89
|
+
* `adapters.profile` were supplied; otherwise `null`. */
|
|
83
90
|
readonly profile: ProfileManager | null;
|
|
91
|
+
/** Build a new suite. See {@link OwnsuiteConfig} for every knob.
|
|
92
|
+
* Account-lifecycle managers are attached only when `adapters.auth`
|
|
93
|
+
* is supplied. */
|
|
84
94
|
constructor(config?: OwnsuiteConfig);
|
|
85
95
|
/** True after `destroy()` has been called. */
|
|
86
96
|
get isDestroyed(): boolean;
|
|
@@ -109,6 +119,7 @@ export declare class Ownsuite {
|
|
|
109
119
|
* linger when, e.g., `subjectId` changes).
|
|
110
120
|
*/
|
|
111
121
|
setContext(ctx: OwnsuiteContext, options?: SetContextOptions): void;
|
|
122
|
+
/** Snapshot of the shared context propagated to every domain adapter. */
|
|
112
123
|
getContext(): OwnsuiteContext;
|
|
113
124
|
/** Subscribe to a specific event type. */
|
|
114
125
|
on(type: OwnsuiteEventType, subscriber: Subscriber): Unsubscriber;
|
package/dist/ownsuite.js
CHANGED
|
@@ -41,11 +41,18 @@ export class Ownsuite {
|
|
|
41
41
|
// deno-lint-ignore no-explicit-any
|
|
42
42
|
#domains = new Map();
|
|
43
43
|
#destroyed = false;
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
44
|
+
/** Session manager. Present iff `adapters.auth` was supplied at
|
|
45
|
+
* construction; otherwise `null`. */
|
|
46
46
|
session = null;
|
|
47
|
+
/** Auth manager (register/login/OAuth verbs). Present iff
|
|
48
|
+
* `adapters.auth` was supplied; otherwise `null`. */
|
|
47
49
|
auth = null;
|
|
50
|
+
/** Profile manager for `/me`. Present iff BOTH `adapters.auth` and
|
|
51
|
+
* `adapters.profile` were supplied; otherwise `null`. */
|
|
48
52
|
profile = null;
|
|
53
|
+
/** Build a new suite. See {@link OwnsuiteConfig} for every knob.
|
|
54
|
+
* Account-lifecycle managers are attached only when `adapters.auth`
|
|
55
|
+
* is supplied. */
|
|
49
56
|
constructor(config = {}) {
|
|
50
57
|
this.#pubsub = createPubSub();
|
|
51
58
|
this.#context = { ...(config.context ?? {}) };
|
|
@@ -221,6 +228,7 @@ export class Ownsuite {
|
|
|
221
228
|
}
|
|
222
229
|
}
|
|
223
230
|
}
|
|
231
|
+
/** Snapshot of the shared context propagated to every domain adapter. */
|
|
224
232
|
getContext() {
|
|
225
233
|
return { ...this.#context };
|
|
226
234
|
}
|
package/dist/types/adapter.d.ts
CHANGED
|
@@ -15,7 +15,9 @@ import type { OwnsuiteContext } from "./state.js";
|
|
|
15
15
|
* whatever shape their server uses and map it here.
|
|
16
16
|
*/
|
|
17
17
|
export interface OwnedListResult<TRow> {
|
|
18
|
+
/** Rows returned by the list call. */
|
|
18
19
|
data: TRow[];
|
|
20
|
+
/** Pagination / total-count / any arbitrary server-supplied metadata. */
|
|
19
21
|
meta: Record<string, unknown>;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
@@ -23,7 +25,9 @@ export interface OwnedListResult<TRow> {
|
|
|
23
25
|
* collection package's REST envelope.
|
|
24
26
|
*/
|
|
25
27
|
export interface OwnedRowResult<TRow> {
|
|
28
|
+
/** The row. */
|
|
26
29
|
data: TRow;
|
|
30
|
+
/** Optional per-row metadata from the server. */
|
|
27
31
|
meta?: Record<string, unknown>;
|
|
28
32
|
}
|
|
29
33
|
/**
|