@marianmeres/ownsuite 1.0.3 → 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 +206 -21
- package/API.md +410 -18
- package/README.md +86 -2
- package/dist/adapters/mock-auth.d.ts +38 -0
- package/dist/adapters/mock-auth.js +237 -0
- package/dist/adapters/mock.d.ts +10 -3
- package/dist/adapters/mock.js +79 -25
- 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/base.d.ts +66 -10
- package/dist/domains/base.js +165 -13
- package/dist/domains/mod.d.ts +3 -0
- package/dist/domains/mod.js +3 -0
- package/dist/domains/owned-collection.d.ts +29 -4
- package/dist/domains/owned-collection.js +240 -120
- 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 +58 -5
- package/dist/ownsuite.js +178 -10
- package/dist/types/adapter.d.ts +4 -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 +17 -0
- package/docs/future-improvements.md +81 -0
- package/package.json +15 -6
package/dist/ownsuite.d.ts
CHANGED
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
* hook and `@marianmeres/stack-common`'s `ownsuiteOptions()` helper.
|
|
12
12
|
*/
|
|
13
13
|
import { type Subscriber, type Unsubscriber } from "@marianmeres/pubsub";
|
|
14
|
-
import type { OwnsuiteContext } from "./types/state.js";
|
|
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,25 @@ 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
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Options for {@link Ownsuite.setContext}. */
|
|
53
|
+
export interface SetContextOptions {
|
|
54
|
+
/** If true, replace the context entirely instead of merging. Default: false (merge). */
|
|
55
|
+
replace?: boolean;
|
|
56
|
+
/** If true, fire `refresh()` on every domain after the context change. Default: false. */
|
|
57
|
+
refresh?: boolean;
|
|
35
58
|
}
|
|
36
59
|
/**
|
|
37
60
|
* Main Ownsuite class — coordinates owner-scoped domain managers.
|
|
@@ -53,7 +76,14 @@ export interface OwnsuiteConfig {
|
|
|
53
76
|
*/
|
|
54
77
|
export declare class Ownsuite {
|
|
55
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;
|
|
56
84
|
constructor(config?: OwnsuiteConfig);
|
|
85
|
+
/** True after `destroy()` has been called. */
|
|
86
|
+
get isDestroyed(): boolean;
|
|
57
87
|
/** Register a new domain after construction. */
|
|
58
88
|
registerDomain<TRow = any, TCreate = any, TUpdate = any>(name: string, cfg: OwnsuiteDomainConfig<TRow, TCreate, TUpdate>): OwnedCollectionManager<TRow, TCreate, TUpdate>;
|
|
59
89
|
/** Look up a domain manager by name. Throws if unknown. */
|
|
@@ -65,11 +95,20 @@ export declare class Ownsuite {
|
|
|
65
95
|
/**
|
|
66
96
|
* Initialize all registered domains (or a subset). Runs in parallel.
|
|
67
97
|
* Individual domain errors land in that domain's error state — they
|
|
68
|
-
* do not reject the overall promise.
|
|
98
|
+
* do not reject the overall promise. Use `hasErrors()` / `errors()` to
|
|
99
|
+
* inspect the result. Unknown domain names in `names` are logged and
|
|
100
|
+
* skipped.
|
|
69
101
|
*/
|
|
70
102
|
initialize(names?: string[]): Promise<void>;
|
|
71
|
-
/**
|
|
72
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Update shared context and propagate to every domain manager.
|
|
105
|
+
*
|
|
106
|
+
* - `options.replace: true` — replace the context wholesale (no merge).
|
|
107
|
+
* - `options.refresh: true` — fire-and-forget `refresh()` on every
|
|
108
|
+
* domain after the context change (so stale per-subject caches don't
|
|
109
|
+
* linger when, e.g., `subjectId` changes).
|
|
110
|
+
*/
|
|
111
|
+
setContext(ctx: OwnsuiteContext, options?: SetContextOptions): void;
|
|
73
112
|
getContext(): OwnsuiteContext;
|
|
74
113
|
/** Subscribe to a specific event type. */
|
|
75
114
|
on(type: OwnsuiteEventType, subscriber: Subscriber): Unsubscriber;
|
|
@@ -78,8 +117,22 @@ export declare class Ownsuite {
|
|
|
78
117
|
* `{ event: string, data: OwnsuiteEvent }` — see `@marianmeres/pubsub`.
|
|
79
118
|
*/
|
|
80
119
|
onAny(subscriber: Subscriber): Unsubscriber;
|
|
81
|
-
/**
|
|
120
|
+
/** Map of currently-errored domains to their error, empty if none. */
|
|
121
|
+
errors(): Record<string, DomainError>;
|
|
122
|
+
/** True if any domain is currently in `error` state. */
|
|
123
|
+
hasErrors(): boolean;
|
|
124
|
+
/** Reset all domains to initializing state. Aborts in-flight ops. */
|
|
82
125
|
reset(): void;
|
|
126
|
+
/**
|
|
127
|
+
* Dispose of the suite: destroys every registered domain (aborting
|
|
128
|
+
* in-flight requests), drops the domain map, and unsubscribes every
|
|
129
|
+
* listener this suite owns on its pubsub. Safe to call multiple times.
|
|
130
|
+
*
|
|
131
|
+
* Note: if the pubsub was constructed internally (the default), all
|
|
132
|
+
* subscribers are unsubscribed. If consumers passed an external pubsub
|
|
133
|
+
* to managers directly, that shared pubsub is not cleared — they own it.
|
|
134
|
+
*/
|
|
135
|
+
destroy(): void;
|
|
83
136
|
}
|
|
84
137
|
/** Convenience factory matching the ecsuite `createECSuite` convention. */
|
|
85
138
|
export declare function createOwnsuite(config?: OwnsuiteConfig): Ownsuite;
|
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
|
*
|
|
@@ -37,20 +40,111 @@ export class Ownsuite {
|
|
|
37
40
|
#context;
|
|
38
41
|
// deno-lint-ignore no-explicit-any
|
|
39
42
|
#domains = new Map();
|
|
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;
|
|
40
49
|
constructor(config = {}) {
|
|
41
50
|
this.#pubsub = createPubSub();
|
|
42
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
|
+
}
|
|
43
108
|
for (const [name, cfg] of Object.entries(config.domains ?? {})) {
|
|
44
109
|
this.registerDomain(name, cfg);
|
|
45
110
|
}
|
|
46
111
|
if (config.autoInitialize) {
|
|
47
|
-
//
|
|
48
|
-
|
|
112
|
+
// `initialize()` is non-rejecting by contract; per-domain errors
|
|
113
|
+
// land in that domain's error state. See `hasErrors()` / `errors()`
|
|
114
|
+
// to detect them after boot.
|
|
115
|
+
void this.initialize();
|
|
116
|
+
}
|
|
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
|
+
}
|
|
49
136
|
}
|
|
50
137
|
}
|
|
138
|
+
/** True after `destroy()` has been called. */
|
|
139
|
+
get isDestroyed() {
|
|
140
|
+
return this.#destroyed;
|
|
141
|
+
}
|
|
51
142
|
/** Register a new domain after construction. */
|
|
52
143
|
// deno-lint-ignore no-explicit-any
|
|
53
144
|
registerDomain(name, cfg) {
|
|
145
|
+
if (this.#destroyed) {
|
|
146
|
+
throw new Error("Ownsuite: cannot register on a destroyed suite");
|
|
147
|
+
}
|
|
54
148
|
if (this.#domains.has(name)) {
|
|
55
149
|
throw new Error(`Ownsuite: domain "${name}" already registered`);
|
|
56
150
|
}
|
|
@@ -82,17 +176,50 @@ export class Ownsuite {
|
|
|
82
176
|
/**
|
|
83
177
|
* Initialize all registered domains (or a subset). Runs in parallel.
|
|
84
178
|
* Individual domain errors land in that domain's error state — they
|
|
85
|
-
* do not reject the overall promise.
|
|
179
|
+
* do not reject the overall promise. Use `hasErrors()` / `errors()` to
|
|
180
|
+
* inspect the result. Unknown domain names in `names` are logged and
|
|
181
|
+
* skipped.
|
|
86
182
|
*/
|
|
87
183
|
async initialize(names) {
|
|
184
|
+
if (this.#destroyed)
|
|
185
|
+
return;
|
|
88
186
|
const targets = names ?? this.domainNames();
|
|
89
|
-
await Promise.all(targets.map((n) =>
|
|
187
|
+
await Promise.all(targets.map((n) => {
|
|
188
|
+
const m = this.#domains.get(n);
|
|
189
|
+
if (!m) {
|
|
190
|
+
this.#clog.warn(`initialize: unknown domain "${n}", skipping`);
|
|
191
|
+
return Promise.resolve();
|
|
192
|
+
}
|
|
193
|
+
return m.initialize();
|
|
194
|
+
}));
|
|
90
195
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Update shared context and propagate to every domain manager.
|
|
198
|
+
*
|
|
199
|
+
* - `options.replace: true` — replace the context wholesale (no merge).
|
|
200
|
+
* - `options.refresh: true` — fire-and-forget `refresh()` on every
|
|
201
|
+
* domain after the context change (so stale per-subject caches don't
|
|
202
|
+
* linger when, e.g., `subjectId` changes).
|
|
203
|
+
*/
|
|
204
|
+
setContext(ctx, options = {}) {
|
|
205
|
+
if (this.#destroyed)
|
|
206
|
+
return;
|
|
207
|
+
this.#context = options.replace
|
|
208
|
+
? { ...ctx }
|
|
209
|
+
: { ...this.#context, ...ctx };
|
|
210
|
+
for (const m of this.#domains.values()) {
|
|
211
|
+
if (options.replace)
|
|
212
|
+
m.replaceContext(this.#context);
|
|
213
|
+
else
|
|
214
|
+
m.setContext(this.#context);
|
|
215
|
+
}
|
|
216
|
+
if (options.refresh) {
|
|
217
|
+
for (const m of this.#domains.values()) {
|
|
218
|
+
// Fire-and-forget. refresh() is non-rejecting (lands in error state
|
|
219
|
+
// on failure), but we defensively swallow anything unexpected.
|
|
220
|
+
void m.refresh().catch((e) => this.#clog.error("setContext: refresh failed", e));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
96
223
|
}
|
|
97
224
|
getContext() {
|
|
98
225
|
return { ...this.#context };
|
|
@@ -108,11 +235,52 @@ export class Ownsuite {
|
|
|
108
235
|
onAny(subscriber) {
|
|
109
236
|
return this.#pubsub.subscribe("*", subscriber);
|
|
110
237
|
}
|
|
111
|
-
/**
|
|
238
|
+
/** Map of currently-errored domains to their error, empty if none. */
|
|
239
|
+
errors() {
|
|
240
|
+
const out = {};
|
|
241
|
+
for (const [name, m] of this.#domains) {
|
|
242
|
+
const s = m.get();
|
|
243
|
+
if (s.state === "error" && s.error)
|
|
244
|
+
out[name] = s.error;
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
/** True if any domain is currently in `error` state. */
|
|
249
|
+
hasErrors() {
|
|
250
|
+
for (const m of this.#domains.values()) {
|
|
251
|
+
if (m.get().state === "error")
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
/** Reset all domains to initializing state. Aborts in-flight ops. */
|
|
112
257
|
reset() {
|
|
113
258
|
for (const m of this.#domains.values())
|
|
114
259
|
m.reset();
|
|
115
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Dispose of the suite: destroys every registered domain (aborting
|
|
263
|
+
* in-flight requests), drops the domain map, and unsubscribes every
|
|
264
|
+
* listener this suite owns on its pubsub. Safe to call multiple times.
|
|
265
|
+
*
|
|
266
|
+
* Note: if the pubsub was constructed internally (the default), all
|
|
267
|
+
* subscribers are unsubscribed. If consumers passed an external pubsub
|
|
268
|
+
* to managers directly, that shared pubsub is not cleared — they own it.
|
|
269
|
+
*/
|
|
270
|
+
destroy() {
|
|
271
|
+
if (this.#destroyed)
|
|
272
|
+
return;
|
|
273
|
+
this.#destroyed = true;
|
|
274
|
+
for (const m of this.#domains.values())
|
|
275
|
+
m.destroy();
|
|
276
|
+
this.#domains.clear();
|
|
277
|
+
this.profile?.destroy();
|
|
278
|
+
this.session?.destroy();
|
|
279
|
+
// Our internal pubsub: clear all subscribers. Best-effort — if a custom
|
|
280
|
+
// pubsub implementation doesn't expose `unsubscribeAll`, skip it.
|
|
281
|
+
const ps = this.#pubsub;
|
|
282
|
+
ps.unsubscribeAll?.();
|
|
283
|
+
}
|
|
116
284
|
}
|
|
117
285
|
/** Convenience factory matching the ecsuite `createECSuite` convention. */
|
|
118
286
|
export function createOwnsuite(config = {}) {
|
package/dist/types/adapter.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ export interface OwnedRowResult<TRow> {
|
|
|
34
34
|
* client. The client can only act on rows it owns.
|
|
35
35
|
* - Errors should throw (ideally `HTTP_ERROR` from `@marianmeres/http-utils`);
|
|
36
36
|
* the manager handles rollback and error state.
|
|
37
|
+
* - `ctx.signal` is populated by the manager on every call — forward it to
|
|
38
|
+
* `fetch(url, { signal: ctx.signal })` to support route-change and
|
|
39
|
+
* destroy cancellation. Ignoring the signal is safe but leaves abandoned
|
|
40
|
+
* requests running to completion (wasted bandwidth; no state corruption).
|
|
37
41
|
*/
|
|
38
42
|
export interface OwnedCollectionAdapter<TRow, TCreate = unknown, TUpdate = unknown> {
|
|
39
43
|
/** List rows owned by the current subject. Query params are implementation-defined. */
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module types/auth
|
|
3
|
+
*
|
|
4
|
+
* Types for the auth / profile / session managers that extend ownsuite with
|
|
5
|
+
* a first-class identity lifecycle (register, login, logout, OAuth link/
|
|
6
|
+
* unlink, email verification, password reset, delete account, profile edit).
|
|
7
|
+
*
|
|
8
|
+
* Design notes:
|
|
9
|
+
* - The adapter pattern mirrors `OwnedCollectionAdapter` — consumers plug in
|
|
10
|
+
* an implementation; no HTTP code lives in managers.
|
|
11
|
+
* - The `SessionManager` is the single source of truth for the JWT and the
|
|
12
|
+
* current subject. Adapters never hold auth state.
|
|
13
|
+
* - OAuth initiation returns a URL — the popup / redirect dance is handled
|
|
14
|
+
* by the `openOAuthPopup` helper (see `oauth/popup.ts`) rather than the
|
|
15
|
+
* adapter itself.
|
|
16
|
+
*/
|
|
17
|
+
import type { OwnsuiteContext } from "./state.js";
|
|
18
|
+
/** Lifecycle state of the current session. */
|
|
19
|
+
export type SessionStatus =
|
|
20
|
+
/** No JWT. Either never logged in or explicitly logged out. */
|
|
21
|
+
"anonymous"
|
|
22
|
+
/** JWT present, subject loaded, login succeeded. */
|
|
23
|
+
| "authenticated"
|
|
24
|
+
/**
|
|
25
|
+
* Account exists and credentials were correct BUT the server is blocking
|
|
26
|
+
* login because the email is not yet verified. UI should surface "check
|
|
27
|
+
* your email" without a second round-trip. Emitted when `auth.login()`
|
|
28
|
+
* or `auth.register()` hits the verification gate.
|
|
29
|
+
*/
|
|
30
|
+
| "unverified";
|
|
31
|
+
/**
|
|
32
|
+
* Minimal subject shape exposed to consumers. Matches what stack-account's
|
|
33
|
+
* `/me` returns plus the fields needed for role-gating.
|
|
34
|
+
*/
|
|
35
|
+
export interface SessionSubject {
|
|
36
|
+
id: string;
|
|
37
|
+
email: string;
|
|
38
|
+
roles: string[];
|
|
39
|
+
isVerified: boolean;
|
|
40
|
+
/** Whether the account has a password (OAuth-only accounts do not). */
|
|
41
|
+
hasPassword: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** Observable session state — stored by the SessionManager. */
|
|
44
|
+
export interface SessionState {
|
|
45
|
+
status: SessionStatus;
|
|
46
|
+
subject: SessionSubject | null;
|
|
47
|
+
jwt: string | null;
|
|
48
|
+
/** Unix-seconds expiry (optional — not every server shape returns one). */
|
|
49
|
+
expiresAt: number | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pluggable storage for persisting session state across reloads. Consumers
|
|
53
|
+
* can pass "local" / "session" / "memory" or supply their own object
|
|
54
|
+
* matching this interface (useful for Tauri, WebExtensions, SSR guards).
|
|
55
|
+
*/
|
|
56
|
+
export interface SessionStorage {
|
|
57
|
+
get(key: string): string | null;
|
|
58
|
+
set(key: string, value: string): void;
|
|
59
|
+
del(key: string): void;
|
|
60
|
+
}
|
|
61
|
+
export type SessionStorageType = "local" | "session" | "memory" | SessionStorage;
|
|
62
|
+
export type OAuthProvider = "google" | "facebook" | "apple" | "twitter";
|
|
63
|
+
export interface OAuthConnection {
|
|
64
|
+
provider: OAuthProvider;
|
|
65
|
+
display_name?: string;
|
|
66
|
+
avatar_url?: string;
|
|
67
|
+
email?: string;
|
|
68
|
+
}
|
|
69
|
+
/** Action verb for the OAuth init URL — `login` creates/looks-up an account,
|
|
70
|
+
* `link` attaches the provider to the currently authenticated subject. */
|
|
71
|
+
export type OAuthAction = "login" | "link";
|
|
72
|
+
export interface OAuthInitOptions {
|
|
73
|
+
action: OAuthAction;
|
|
74
|
+
/** Where to redirect after the provider callback (server honours this). */
|
|
75
|
+
redirect?: string;
|
|
76
|
+
/** Language code forwarded to the server for error-page localization. */
|
|
77
|
+
lang?: string;
|
|
78
|
+
/** `"popup"` (default) or `"redirect"` — the manager uses this to decide
|
|
79
|
+
* whether to open a popup and wait for a postMessage, or redirect the
|
|
80
|
+
* top window. */
|
|
81
|
+
mode?: "popup" | "redirect";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Uniform result shape for `register` / `login` / OAuth success. When the
|
|
85
|
+
* server returns `requiresVerification: true` (i.e. the verification gate is
|
|
86
|
+
* on and the email is not yet verified), the JWT will be absent and the
|
|
87
|
+
* manager flips session.status to "unverified".
|
|
88
|
+
*/
|
|
89
|
+
export interface AuthTokenResult {
|
|
90
|
+
jwt?: string;
|
|
91
|
+
email: string;
|
|
92
|
+
roles: string[];
|
|
93
|
+
isVerified?: boolean;
|
|
94
|
+
validFrom?: number;
|
|
95
|
+
validUntil?: number;
|
|
96
|
+
/** Set by the server when login auto-login is declined for a not-yet-
|
|
97
|
+
* verified account. Mutually exclusive with jwt in practice. */
|
|
98
|
+
requiresVerification?: boolean;
|
|
99
|
+
}
|
|
100
|
+
export interface ProfileResult {
|
|
101
|
+
email: string;
|
|
102
|
+
roles: string[];
|
|
103
|
+
isVerified: boolean;
|
|
104
|
+
hasPassword: boolean;
|
|
105
|
+
oauthConnections: OAuthConnection[];
|
|
106
|
+
}
|
|
107
|
+
export interface AuthAdapter {
|
|
108
|
+
register(input: {
|
|
109
|
+
email: string;
|
|
110
|
+
password: string;
|
|
111
|
+
password_confirm: string;
|
|
112
|
+
roles?: string[];
|
|
113
|
+
/** Optional extras — consumer forwards any configured
|
|
114
|
+
* registrationFields the server expects. */
|
|
115
|
+
extras?: Record<string, unknown>;
|
|
116
|
+
}, ctx: OwnsuiteContext): Promise<AuthTokenResult>;
|
|
117
|
+
login(input: {
|
|
118
|
+
email: string;
|
|
119
|
+
password: string;
|
|
120
|
+
}, ctx: OwnsuiteContext): Promise<AuthTokenResult>;
|
|
121
|
+
/** Best-effort server-side revocation. Must not throw on
|
|
122
|
+
* already-anonymous state. */
|
|
123
|
+
logout(ctx: OwnsuiteContext): Promise<void>;
|
|
124
|
+
/** Sync URL builder. The manager opens this URL in a popup (default) or
|
|
125
|
+
* redirects the top window depending on mode. */
|
|
126
|
+
oauthInitUrl(provider: OAuthProvider, opts: OAuthInitOptions, ctx: OwnsuiteContext): string;
|
|
127
|
+
/** For the redirect-mode callback path: extract result from the current
|
|
128
|
+
* URL (or a server-rendered payload) and return it. Popup-mode uses the
|
|
129
|
+
* `openOAuthPopup` helper directly and does not call this. */
|
|
130
|
+
handleOAuthCallback?(ctx: OwnsuiteContext): Promise<AuthTokenResult>;
|
|
131
|
+
resendVerification(input: {
|
|
132
|
+
email: string;
|
|
133
|
+
lang?: string;
|
|
134
|
+
}, ctx: OwnsuiteContext): Promise<void>;
|
|
135
|
+
requestPasswordReset(input: {
|
|
136
|
+
email: string;
|
|
137
|
+
lang?: string;
|
|
138
|
+
}, ctx: OwnsuiteContext): Promise<void>;
|
|
139
|
+
changePassword(input: {
|
|
140
|
+
/** Required for authenticated self-change. */
|
|
141
|
+
current_password?: string;
|
|
142
|
+
new_password: string;
|
|
143
|
+
confirm_password: string;
|
|
144
|
+
/** Required for token-based reset. */
|
|
145
|
+
token?: string;
|
|
146
|
+
}, ctx: OwnsuiteContext): Promise<void>;
|
|
147
|
+
deleteAccount(input: {
|
|
148
|
+
password?: string;
|
|
149
|
+
confirm?: boolean;
|
|
150
|
+
}, ctx: OwnsuiteContext): Promise<{
|
|
151
|
+
deleted: true;
|
|
152
|
+
}>;
|
|
153
|
+
}
|
|
154
|
+
export interface ProfileAdapter {
|
|
155
|
+
get(ctx: OwnsuiteContext): Promise<ProfileResult>;
|
|
156
|
+
update(input: {
|
|
157
|
+
email?: string;
|
|
158
|
+
current_password?: string;
|
|
159
|
+
}, ctx: OwnsuiteContext): Promise<ProfileResult>;
|
|
160
|
+
listOAuth(ctx: OwnsuiteContext): Promise<OAuthConnection[]>;
|
|
161
|
+
unlinkOAuth(provider: OAuthProvider, ctx: OwnsuiteContext): Promise<void>;
|
|
162
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module types/auth
|
|
3
|
+
*
|
|
4
|
+
* Types for the auth / profile / session managers that extend ownsuite with
|
|
5
|
+
* a first-class identity lifecycle (register, login, logout, OAuth link/
|
|
6
|
+
* unlink, email verification, password reset, delete account, profile edit).
|
|
7
|
+
*
|
|
8
|
+
* Design notes:
|
|
9
|
+
* - The adapter pattern mirrors `OwnedCollectionAdapter` — consumers plug in
|
|
10
|
+
* an implementation; no HTTP code lives in managers.
|
|
11
|
+
* - The `SessionManager` is the single source of truth for the JWT and the
|
|
12
|
+
* current subject. Adapters never hold auth state.
|
|
13
|
+
* - OAuth initiation returns a URL — the popup / redirect dance is handled
|
|
14
|
+
* by the `openOAuthPopup` helper (see `oauth/popup.ts`) rather than the
|
|
15
|
+
* adapter itself.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/types/events.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Event type definitions for the ownsuite event system.
|
|
5
5
|
*/
|
|
6
6
|
import type { DomainError, DomainState } from "./state.js";
|
|
7
|
+
import type { OAuthConnection, OAuthProvider, SessionState } from "./auth.js";
|
|
7
8
|
/**
|
|
8
9
|
* Domain identifier in ownsuite is an arbitrary string (the collection name
|
|
9
10
|
* or any label the consumer chose), unlike ecsuite's fixed enum of six
|
|
@@ -11,7 +12,7 @@ import type { DomainError, DomainState } from "./state.js";
|
|
|
11
12
|
*/
|
|
12
13
|
export type DomainName = string;
|
|
13
14
|
/** Event types emitted by the suite. */
|
|
14
|
-
export type OwnsuiteEventType = "domain:state:changed" | "domain:error" | "domain:synced" | "own:list:fetched" | "own:row:fetched" | "own:row:created" | "own:row:updated" | "own:row:deleted";
|
|
15
|
+
export type OwnsuiteEventType = "domain:state:changed" | "domain:error" | "domain:synced" | "own:list:fetched" | "own:row:fetched" | "own:row:created" | "own:row:updated" | "own:row:deleted" | "auth:register" | "auth:login" | "auth:logout" | "auth:session:changed" | "auth:verification:required" | "profile:updated" | "oauth:linked" | "oauth:unlinked";
|
|
15
16
|
/** Base event data. */
|
|
16
17
|
export interface OwnsuiteEventBase {
|
|
17
18
|
/** Event timestamp */
|
|
@@ -59,5 +60,43 @@ export interface RowDeletedEvent extends OwnsuiteEventBase {
|
|
|
59
60
|
type: "own:row:deleted";
|
|
60
61
|
rowId: string;
|
|
61
62
|
}
|
|
63
|
+
export interface AuthEventBase {
|
|
64
|
+
timestamp: number;
|
|
65
|
+
}
|
|
66
|
+
export interface AuthRegisterEvent extends AuthEventBase {
|
|
67
|
+
type: "auth:register";
|
|
68
|
+
email: string;
|
|
69
|
+
/** True when the server requires email verification (no auto-login). */
|
|
70
|
+
requiresVerification: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface AuthLoginEvent extends AuthEventBase {
|
|
73
|
+
type: "auth:login";
|
|
74
|
+
email: string;
|
|
75
|
+
}
|
|
76
|
+
export interface AuthLogoutEvent extends AuthEventBase {
|
|
77
|
+
type: "auth:logout";
|
|
78
|
+
/** Id of the subject that just logged out, if known. */
|
|
79
|
+
subjectId?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface AuthSessionChangedEvent extends AuthEventBase {
|
|
82
|
+
type: "auth:session:changed";
|
|
83
|
+
session: SessionState;
|
|
84
|
+
}
|
|
85
|
+
export interface AuthVerificationRequiredEvent extends AuthEventBase {
|
|
86
|
+
type: "auth:verification:required";
|
|
87
|
+
email: string;
|
|
88
|
+
}
|
|
89
|
+
export interface ProfileUpdatedEvent extends AuthEventBase {
|
|
90
|
+
type: "profile:updated";
|
|
91
|
+
email: string;
|
|
92
|
+
}
|
|
93
|
+
export interface OAuthLinkedEvent extends AuthEventBase {
|
|
94
|
+
type: "oauth:linked";
|
|
95
|
+
connection: OAuthConnection;
|
|
96
|
+
}
|
|
97
|
+
export interface OAuthUnlinkedEvent extends AuthEventBase {
|
|
98
|
+
type: "oauth:unlinked";
|
|
99
|
+
provider: OAuthProvider;
|
|
100
|
+
}
|
|
62
101
|
/** All event types union. */
|
|
63
|
-
export type OwnsuiteEvent = StateChangedEvent | ErrorEvent | SyncedEvent | ListFetchedEvent | RowFetchedEvent | RowCreatedEvent | RowUpdatedEvent | RowDeletedEvent;
|
|
102
|
+
export type OwnsuiteEvent = StateChangedEvent | ErrorEvent | SyncedEvent | ListFetchedEvent | RowFetchedEvent | RowCreatedEvent | RowUpdatedEvent | RowDeletedEvent | AuthRegisterEvent | AuthLoginEvent | AuthLogoutEvent | AuthSessionChangedEvent | AuthVerificationRequiredEvent | ProfileUpdatedEvent | OAuthLinkedEvent | OAuthUnlinkedEvent;
|
package/dist/types/mod.d.ts
CHANGED
package/dist/types/mod.js
CHANGED
package/dist/types/state.d.ts
CHANGED
|
@@ -34,10 +34,27 @@ export interface DomainStateWrapper<T> {
|
|
|
34
34
|
* its own `ownerId` — the server resolves it from the authenticated subject
|
|
35
35
|
* via the `/me/*` mount. The context is still provided so adapters can pass
|
|
36
36
|
* arbitrary host-app data (correlation ids, feature flags, etc.) through.
|
|
37
|
+
*
|
|
38
|
+
* The manager also injects a per-operation `signal` into `ctx` for every
|
|
39
|
+
* adapter call. Adapters that care about cancellation should forward it
|
|
40
|
+
* to `fetch()`; adapters that don't can ignore it.
|
|
37
41
|
*/
|
|
38
42
|
export interface OwnsuiteContext {
|
|
39
43
|
/** Hint — not used for authorization. The server is authoritative. */
|
|
40
44
|
subjectId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* JWT to pass through to adapters as the `Authorization: Bearer <jwt>`
|
|
47
|
+
* credential. Managed by the `SessionManager` — consumers don't set this
|
|
48
|
+
* directly. When present, adapters must forward it. When absent, the
|
|
49
|
+
* server will treat the request as anonymous.
|
|
50
|
+
*/
|
|
51
|
+
jwt?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Per-operation abort signal injected by the manager. Adapters should
|
|
54
|
+
* forward this to `fetch(url, { signal: ctx.signal })`. Aborts fire on
|
|
55
|
+
* `reset()`, `destroy()`, and when a newer read supersedes an older one.
|
|
56
|
+
*/
|
|
57
|
+
signal?: AbortSignal;
|
|
41
58
|
/** Additional context properties for adapter-specific needs. */
|
|
42
59
|
[key: string]: unknown;
|
|
43
60
|
}
|