@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.
@@ -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
- /** Update shared context and propagate to all domain managers. */
72
- setContext(ctx: OwnsuiteContext): void;
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
- /** Reset all domains to initializing state. */
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
- // fire-and-forget; consumers who care should await initialize() explicitly
48
- this.initialize().catch((e) => this.#clog.error("autoInitialize", e));
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) => this.#domains.get(n)?.initialize() ?? Promise.resolve()));
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
- /** Update shared context and propagate to all domain managers. */
92
- setContext(ctx) {
93
- this.#context = { ...this.#context, ...ctx };
94
- for (const m of this.#domains.values())
95
- m.setContext(this.#context);
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
- /** Reset all domains to initializing state. */
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 = {}) {
@@ -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 {};
@@ -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;
@@ -1,3 +1,4 @@
1
1
  export * from "./state.js";
2
2
  export * from "./events.js";
3
3
  export * from "./adapter.js";
4
+ export * from "./auth.js";
package/dist/types/mod.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./state.js";
2
2
  export * from "./events.js";
3
3
  export * from "./adapter.js";
4
+ export * from "./auth.js";
@@ -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
  }