@jxsuite/auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Authorize — the pure permission evaluator implementing the connector's auth contract
3
+ * (specs/extensions.md §11, @jxsuite/connector/types).
4
+ *
5
+ * The data mount resolves each request's rule (declared permissions over the defaults: reads
6
+ * public, writes closed), handles `public`/`none` itself, and hands every other rule to
7
+ * `ctx.auth.authorize` — this function. Grants may carry `setColumns` (owner id forced onto
8
+ * insert/update payloads) and `whereOwner` (row scoping for owner reads/writes/deletes). Unknown
9
+ * rules fail closed. No I/O: the session was already resolved by `getSession`.
10
+ */
11
+
12
+ import type {
13
+ AuthorizeDecision,
14
+ AuthorizeInput,
15
+ PermissionRule,
16
+ SessionInfo,
17
+ } from "@jxsuite/connector/types";
18
+
19
+ /** A 401/403 denial with a wire-friendly message. */
20
+ function deny(session: SessionInfo | null, error: string, status?: number): AuthorizeDecision {
21
+ return { allow: false, error, status: status ?? (session ? 403 : 401) };
22
+ }
23
+
24
+ /**
25
+ * A grant for a session-backed rule. Declaring an `ownerField` makes that column authoritative:
26
+ * EVERY session-granted insert stamps it with the user id (not just `owner`-rule inserts) —
27
+ * otherwise an `insert: "authenticated"` table would accept payload-forged owner ids, poisoning
28
+ * later owner-scoped reads and writes.
29
+ */
30
+ function grant(input: AuthorizeInput, session: SessionInfo): AuthorizeDecision {
31
+ const { action, ownerField } = input;
32
+ if (action === "insert" && ownerField) {
33
+ return { allow: true, setColumns: { [ownerField]: session.userId } };
34
+ }
35
+ return { allow: true };
36
+ }
37
+
38
+ /** The role a rule requires, or null for non-role rules. */
39
+ export function ruleRole(rule: PermissionRule): string | null {
40
+ return rule.startsWith("role:") ? rule.slice("role:".length) : null;
41
+ }
42
+
43
+ /**
44
+ * Evaluate one permission rule against a resolved session (pure, synchronous).
45
+ *
46
+ * @param {AuthorizeInput} input - Table, action, resolved rule, session, and optional ownerField
47
+ * @returns {AuthorizeDecision} The grant (with setColumns/whereOwner for owner rules) or denial
48
+ */
49
+ export function evaluatePermission(input: AuthorizeInput): AuthorizeDecision {
50
+ const { table, action, rule, session } = input;
51
+
52
+ if (rule === "public") {
53
+ return { allow: true };
54
+ }
55
+ if (rule === "none") {
56
+ return deny(session, `${action} is disabled for "${table}"`, 403);
57
+ }
58
+ if (rule === "authenticated") {
59
+ return session
60
+ ? grant(input, session)
61
+ : deny(session, `${action} on "${table}" requires authentication`);
62
+ }
63
+ if (rule === "owner") {
64
+ const { ownerField } = input;
65
+ if (!ownerField) {
66
+ // Configuration error: an owner rule is unenforceable without an ownerField column.
67
+ return deny(
68
+ session,
69
+ `"${table}" uses an "owner" rule for ${action} but declares no ownerField`,
70
+ 403,
71
+ );
72
+ }
73
+ if (!session) {
74
+ return deny(session, `${action} on "${table}" requires authentication`);
75
+ }
76
+ if (action === "insert") {
77
+ return grant(input, session);
78
+ }
79
+ return { allow: true, whereOwner: { field: ownerField, value: session.userId } };
80
+ }
81
+ const role = ruleRole(rule);
82
+ if (role !== null) {
83
+ if (!session) {
84
+ return deny(session, `${action} on "${table}" requires authentication`);
85
+ }
86
+ return session.role === role
87
+ ? grant(input, session)
88
+ : deny(session, `${action} on "${table}" requires the "${role}" role`, 403);
89
+ }
90
+ // Fail closed on rules this evaluator does not understand.
91
+ return deny(session, `Unknown permission rule "${String(rule)}" for "${table}"`, 403);
92
+ }
package/src/client.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Client — the browser-safe Better Auth client wrapper.
3
+ *
4
+ * Wraps `createAuthClient` from better-auth/client (verified against 1.6.23: `signIn.email`,
5
+ * `signUp.email`, `signIn.social`, `signOut`, `getSession`, each returning `{ data, error }`)
6
+ * behind a module-level singleton pointed at the site's own /_jx/auth mount. Everything here is
7
+ * typed structurally against the narrow slice the session store and auth actions consume, so
8
+ * bundles never depend on Better Auth's inferred mega-types.
9
+ */
10
+
11
+ import { createAuthClient } from "better-auth/client";
12
+ import { AUTH_BASE_PATH } from "./config.ts";
13
+
14
+ /** The `{ data, error }` envelope every client call resolves to. */
15
+ export interface AuthClientResult {
16
+ data?: unknown;
17
+ error?: { message?: string; status?: number } | null;
18
+ }
19
+
20
+ /** The Better Auth client slice consumed by the session store and the auth actions. */
21
+ export interface JxAuthClient {
22
+ signIn: {
23
+ email: (input: {
24
+ email: string;
25
+ password: string;
26
+ callbackURL?: string;
27
+ }) => Promise<AuthClientResult>;
28
+ social: (input: { provider: string; callbackURL?: string }) => Promise<AuthClientResult>;
29
+ };
30
+ signUp: {
31
+ email: (input: { email: string; password: string; name: string }) => Promise<AuthClientResult>;
32
+ };
33
+ signOut: () => Promise<AuthClientResult>;
34
+ getSession: () => Promise<AuthClientResult>;
35
+ }
36
+
37
+ let client: JxAuthClient | null = null;
38
+ let clientBase: string | null = null;
39
+
40
+ /** Sentinel base marking a host/test-injected client (always returned as-is). */
41
+ const INJECTED = "__injected__";
42
+
43
+ /** The auth route base URL: explicit, else the page's own origin + /_jx/auth. */
44
+ export function resolveAuthBaseUrl(baseUrl?: string): string {
45
+ if (baseUrl) {
46
+ return baseUrl;
47
+ }
48
+ if (typeof location === "undefined") {
49
+ throw new TypeError("The jx auth client needs an explicit baseUrl outside browsers");
50
+ }
51
+ return `${location.origin}${AUTH_BASE_PATH}`;
52
+ }
53
+
54
+ /**
55
+ * The shared auth client (created on first use, re-created when the base URL changes).
56
+ *
57
+ * @param {string} [baseUrl] - Auth route base URL; defaults to <origin>/_jx/auth
58
+ * @returns {JxAuthClient}
59
+ */
60
+ export function getAuthClient(baseUrl?: string): JxAuthClient {
61
+ if (client !== null && clientBase === INJECTED) {
62
+ return client;
63
+ }
64
+ const base = resolveAuthBaseUrl(baseUrl);
65
+ if (!client || clientBase !== base) {
66
+ client = createAuthClient({ baseURL: base }) as unknown as JxAuthClient;
67
+ clientBase = base;
68
+ }
69
+ return client;
70
+ }
71
+
72
+ /** Inject a client instance (host/test hook; pass null to restore the default factory). */
73
+ export function setAuthClient(next: JxAuthClient | null): void {
74
+ client = next;
75
+ clientBase = next === null ? null : INJECTED;
76
+ }
package/src/config.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Config — the project.json `auth` section and its mapping onto BetterAuthOptions.
3
+ *
4
+ * The section carries identifiers and env-var NAMES only (specs/extensions.md §13): the signing
5
+ * secret comes from `env[secretEnv]` (default BETTER_AUTH_SECRET), social-provider credentials from
6
+ * their `clientIdEnv`/`clientSecretEnv` vars (defaults <ID>_CLIENT_ID / <ID>_CLIENT_SECRET). This
7
+ * module is pure and dependency-free so browser bundles (session store, actions) can share the
8
+ * section types and the SessionInfo mapping without pulling the Better Auth server in.
9
+ */
10
+
11
+ import type { SessionInfo } from "@jxsuite/connector/types";
12
+
13
+ /** The Better Auth route subtree (specs/extensions.md §11: the auth mount's basePath). */
14
+ export const AUTH_BASE_PATH = "/_jx/auth";
15
+
16
+ /** Default NAME of the env var holding the Better Auth signing secret. */
17
+ export const DEFAULT_SECRET_ENV = "BETTER_AUTH_SECRET";
18
+
19
+ /** One social provider entry: env-var NAMES for the OAuth credentials, never values. */
20
+ export interface AuthProviderDef {
21
+ clientIdEnv?: string;
22
+ clientSecretEnv?: string;
23
+ }
24
+
25
+ /** The project.json `auth` section (identifiers and env-var names only). */
26
+ export interface AuthSection {
27
+ /** Connection holding the auth system tables; defaults to the first-declared connection. */
28
+ connection?: string;
29
+ secretEnv?: string;
30
+ methods?: { emailPassword?: boolean };
31
+ providers?: Record<string, AuthProviderDef>;
32
+ redirects?: { afterSignIn?: string; afterSignOut?: string };
33
+ roles?: string[];
34
+ trustedOrigins?: string[];
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /** The `connections`-bearing slice of a project config (the connector owns the full type). */
39
+ export interface AuthProjectConfig {
40
+ connections?: Record<string, { provider: string; [key: string]: unknown }>;
41
+ auth?: AuthSection;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ type Env = Record<string, unknown>;
46
+
47
+ /** The connection name the auth tables live on: declared, else the first-declared connection. */
48
+ export function resolveAuthConnectionName(
49
+ section: AuthSection,
50
+ projectConfig: AuthProjectConfig,
51
+ ): string {
52
+ const name = section.connection ?? Object.keys(projectConfig.connections ?? {})[0];
53
+ if (!name) {
54
+ throw new Error(
55
+ 'The "auth" section needs a database connection, but the project declares none ' +
56
+ '(add a "connections" entry, or set auth.connection)',
57
+ );
58
+ }
59
+ return name;
60
+ }
61
+
62
+ /** The signing secret from `env[secretEnv]`, failing closed with the env-var name when unset. */
63
+ export function resolveAuthSecret(section: AuthSection, env: Env): string {
64
+ const name = section.secretEnv ?? DEFAULT_SECRET_ENV;
65
+ const value = env[name];
66
+ if (typeof value !== "string" || value === "") {
67
+ throw new Error(
68
+ `Auth is not configured: set the ${name} secret (in .dev.vars locally, ` +
69
+ `or \`wrangler secret put ${name}\` in production)`,
70
+ );
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** Default env-var NAME for a provider credential (`github` → `GITHUB_CLIENT_ID`). */
76
+ function defaultProviderEnv(providerId: string, suffix: "CLIENT_ID" | "CLIENT_SECRET"): string {
77
+ return `${providerId.toUpperCase().replaceAll(/[^A-Z0-9]+/g, "_")}_${suffix}`;
78
+ }
79
+
80
+ /**
81
+ * Social providers with resolvable credentials, in Better Auth's `socialProviders` shape. Providers
82
+ * whose env vars are unset are omitted (fail-closed: no half-configured OAuth).
83
+ */
84
+ export function resolveSocialProviders(
85
+ section: AuthSection,
86
+ env: Env,
87
+ ): Record<string, { clientId: string; clientSecret: string }> {
88
+ const out: Record<string, { clientId: string; clientSecret: string }> = {};
89
+ for (const [id, def] of Object.entries(section.providers ?? {})) {
90
+ const clientId = env[def.clientIdEnv ?? defaultProviderEnv(id, "CLIENT_ID")];
91
+ const clientSecret = env[def.clientSecretEnv ?? defaultProviderEnv(id, "CLIENT_SECRET")];
92
+ if (typeof clientId === "string" && clientId !== "" && typeof clientSecret === "string") {
93
+ out[id] = { clientId, clientSecret };
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Map the auth section + env onto BetterAuthOptions (typed structurally — the exact
101
+ * BetterAuthOptions type lives in better-auth, which this pure module never imports).
102
+ *
103
+ * @param {AuthSection} section
104
+ * @param {Env} env
105
+ * @param {object} [extras]
106
+ * @param {unknown} [extras.database] - Better Auth database config ({ dialect, type })
107
+ * @param {string} [extras.secret] - Signing secret (omit for secret-free uses like migrations)
108
+ * @returns {Record<string, unknown>} Options for `betterAuth()` / `getMigrations()`
109
+ */
110
+ export function buildAuthOptions(
111
+ section: AuthSection,
112
+ env: Env,
113
+ extras: { database?: unknown; secret?: string } = {},
114
+ ): Record<string, unknown> {
115
+ const socialProviders = resolveSocialProviders(section, env);
116
+ const roles = section.roles ?? [];
117
+ const baseURL = env["BETTER_AUTH_URL"];
118
+ return {
119
+ // Without BETTER_AUTH_URL Better Auth derives the origin from each incoming request, which
120
+ // Is correct for same-origin /_jx/auth mounts; setting it pins callbacks for OAuth flows.
121
+ ...(typeof baseURL === "string" && baseURL !== "" ? { baseURL } : {}),
122
+ basePath: AUTH_BASE_PATH,
123
+ emailAndPassword: { enabled: section.methods?.emailPassword ?? true },
124
+ ...(Object.keys(socialProviders).length > 0 ? { socialProviders } : {}),
125
+ ...(roles.length > 0
126
+ ? {
127
+ user: {
128
+ additionalFields: {
129
+ // Roles are assigned via the data grid, never by sign-up input.
130
+ role: { input: false, required: false, type: "string" },
131
+ },
132
+ },
133
+ }
134
+ : {}),
135
+ ...(section.trustedOrigins && section.trustedOrigins.length > 0
136
+ ? { trustedOrigins: section.trustedOrigins }
137
+ : {}),
138
+ ...(extras.database === undefined ? {} : { database: extras.database }),
139
+ ...(extras.secret === undefined ? {} : { secret: extras.secret }),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Map a Better Auth session payload ({ user, session }) onto the connector's SessionInfo shape.
145
+ *
146
+ * @param {unknown} data - `auth.api.getSession` result or the client's `getSession().data`
147
+ * @returns {SessionInfo | null}
148
+ */
149
+ export function toSessionInfo(data: unknown): SessionInfo | null {
150
+ if (!data || typeof data !== "object") {
151
+ return null;
152
+ }
153
+ const { user, session } = data as { user?: Record<string, unknown>; session?: unknown };
154
+ if (!user || typeof user.id !== "string") {
155
+ return null;
156
+ }
157
+ return {
158
+ userId: user.id,
159
+ ...(typeof user.role === "string" && user.role !== "" ? { role: user.role } : {}),
160
+ user,
161
+ ...(session === undefined ? {} : { session }),
162
+ };
163
+ }
package/src/server.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Server — the Better Auth instance factory and migration planner (node/Bun/Workers only).
3
+ *
4
+ * `createJxAuth` resolves the auth database through the connector: a host-provided provider map
5
+ * (`options.connectors`, carrying the dev server's `local: "sqlite"` stand-ins) when available,
6
+ * else `resolveDialect` over the first-party providers — either way yielding Better Auth's
7
+ * `database: { dialect, type }` Kysely config. `getAuthMigrations` compiles Better Auth's additive
8
+ * system-table sync (verified against better-auth 1.6.23: `getMigrations` from
9
+ * `better-auth/db/migration` returns { toBeCreated, toBeAdded, runMigrations, compileMigrations })
10
+ * into push steps of kind "auth", composed by the host after the connector plan — the connector
11
+ * never special-cases auth.
12
+ */
13
+
14
+ import { betterAuth } from "better-auth";
15
+ import { getMigrations } from "better-auth/db/migration";
16
+ import { resolveDialect } from "@jxsuite/connector";
17
+ import {
18
+ buildAuthOptions,
19
+ resolveAuthConnectionName,
20
+ resolveAuthSecret,
21
+ toSessionInfo,
22
+ } from "./config.ts";
23
+ import type { Dialect } from "kysely";
24
+ import type { ConnectionDef, SessionInfo } from "@jxsuite/connector/types";
25
+ import type { AuthProjectConfig, AuthSection } from "./config.ts";
26
+
27
+ type Env = Record<string, unknown>;
28
+
29
+ /** The provider surface the auth extension needs (a slice of ConnectorProvider). */
30
+ export interface AuthConnectorProvider {
31
+ kind: "sqlite" | "postgres";
32
+ dialect: (connection: ConnectionDef, env: Env) => Dialect | Promise<Dialect>;
33
+ }
34
+
35
+ /** The Better Auth surface consumed here (structural — betterAuth() returns a far larger type). */
36
+ export interface JxAuthInstance {
37
+ handler: (request: Request) => Promise<Response>;
38
+ api: { getSession: (input: { headers: Headers }) => Promise<unknown> };
39
+ }
40
+
41
+ export interface CreateJxAuthOptions {
42
+ /** Provider implementations keyed by `connector.provider` id (dev stand-ins applied by hosts). */
43
+ connectors?: Record<string, AuthConnectorProvider> | undefined;
44
+ /** Run Better Auth's additive migrations before serving (dev-server first-touch sync). */
45
+ autoSync?: boolean | undefined;
46
+ }
47
+
48
+ /** Resolve the auth connection's Kysely dialect + type, preferring host-provided stand-ins. */
49
+ async function resolveAuthDatabase(
50
+ section: AuthSection,
51
+ projectConfig: AuthProjectConfig,
52
+ env: Env,
53
+ connectors?: Record<string, AuthConnectorProvider>,
54
+ ): Promise<{ dialect: Dialect; type: "sqlite" | "postgres"; connection: string }> {
55
+ const connection = resolveAuthConnectionName(section, projectConfig);
56
+ const def = projectConfig.connections?.[connection];
57
+ if (!def) {
58
+ throw new Error(`Auth names unknown connection "${connection}" (no such connections entry)`);
59
+ }
60
+ const provider = connectors?.[def.provider];
61
+ if (provider) {
62
+ const dialect = await provider.dialect({ ...def, $name: connection } as ConnectionDef, env);
63
+ return { connection, dialect, type: provider.kind };
64
+ }
65
+ const resolved = await resolveDialect(connection, projectConfig, env);
66
+ return { connection, dialect: resolved.dialect, type: resolved.type };
67
+ }
68
+
69
+ /**
70
+ * Create a Better Auth instance for a project's auth section.
71
+ *
72
+ * @param {AuthSection} section - The project.json `auth` section
73
+ * @param {AuthProjectConfig} projectConfig - Project config (connections lookup)
74
+ * @param {Env} env - Host environment (bindings + env vars; the secret lives here)
75
+ * @param {CreateJxAuthOptions} [options]
76
+ * @returns {Promise<JxAuthInstance>}
77
+ */
78
+ export async function createJxAuth(
79
+ section: AuthSection,
80
+ projectConfig: AuthProjectConfig,
81
+ env: Env,
82
+ options: CreateJxAuthOptions = {},
83
+ ): Promise<JxAuthInstance> {
84
+ const secret = resolveAuthSecret(section, env);
85
+ const { dialect, type } = await resolveAuthDatabase(
86
+ section,
87
+ projectConfig,
88
+ env,
89
+ options.connectors,
90
+ );
91
+ const authOptions = buildAuthOptions(section, env, {
92
+ database: { dialect, type },
93
+ secret,
94
+ });
95
+ if (options.autoSync) {
96
+ const migrations = await getMigrations(authOptions as Parameters<typeof getMigrations>[0]);
97
+ await migrations.runMigrations();
98
+ }
99
+ return betterAuth(authOptions as Parameters<typeof betterAuth>[0]) as JxAuthInstance;
100
+ }
101
+
102
+ /**
103
+ * Resolve a request's session through Better Auth into the connector's SessionInfo shape.
104
+ *
105
+ * @param {JxAuthInstance} auth
106
+ * @param {Request} request
107
+ * @returns {Promise<SessionInfo | null>}
108
+ */
109
+ export async function getSessionContext(
110
+ auth: JxAuthInstance,
111
+ request: Request,
112
+ ): Promise<SessionInfo | null> {
113
+ const data = await auth.api.getSession({ headers: request.headers });
114
+ return toSessionInfo(data);
115
+ }
116
+
117
+ // ─── Migrations (push plan) ───────────────────────────────────────────────────
118
+
119
+ /** One push step of the auth migration plan (protocol DataPushStep shape, kind "auth"). */
120
+ export interface AuthPushStep {
121
+ kind: "auth";
122
+ table?: string;
123
+ summary: string;
124
+ sql: string;
125
+ connection: string;
126
+ }
127
+
128
+ export interface AuthMigrationsResult {
129
+ steps: AuthPushStep[];
130
+ /** Execute the compiled migration (no-op when steps is empty). */
131
+ apply: () => Promise<void>;
132
+ /** The connection the auth tables live on. */
133
+ connection: string;
134
+ }
135
+
136
+ /** Split a compiled migration blob into single SQL statements. */
137
+ export function splitSqlStatements(sqlBlob: string): string[] {
138
+ return sqlBlob
139
+ .split(";")
140
+ .map((statement) => statement.trim())
141
+ .filter((statement) => statement !== "");
142
+ }
143
+
144
+ /** Classify one auth DDL statement into a push step (kind "auth" throughout). */
145
+ export function classifyAuthStatement(statement: string, connection: string): AuthPushStep {
146
+ const createTable = statement.match(/^create table "?([\w.]+)"?/i);
147
+ if (createTable) {
148
+ return {
149
+ connection,
150
+ kind: "auth",
151
+ sql: statement,
152
+ summary: `Create auth table "${createTable[1]!}"`,
153
+ table: createTable[1]!,
154
+ };
155
+ }
156
+ const addColumn = statement.match(/^alter table "?([\w.]+)"? add column "?([\w.]+)"?/i);
157
+ if (addColumn) {
158
+ return {
159
+ connection,
160
+ kind: "auth",
161
+ sql: statement,
162
+ summary: `Add auth column "${addColumn[2]!}" to "${addColumn[1]!}"`,
163
+ table: addColumn[1]!,
164
+ };
165
+ }
166
+ const index = statement.match(/^create (?:unique )?index "?([\w.]+)"? on "?([\w.]+)"?/i);
167
+ if (index) {
168
+ return {
169
+ connection,
170
+ kind: "auth",
171
+ sql: statement,
172
+ summary: `Create auth index "${index[1]!}" on "${index[2]!}"`,
173
+ table: index[2]!,
174
+ };
175
+ }
176
+ return { connection, kind: "auth", sql: statement, summary: statement };
177
+ }
178
+
179
+ /**
180
+ * Compile Better Auth's additive system-table migration into push steps plus an apply function. The
181
+ * secret is intentionally not required here — planning and pushing schemas must work before the
182
+ * signing secret is provisioned.
183
+ *
184
+ * @param {AuthSection} section
185
+ * @param {AuthProjectConfig} projectConfig
186
+ * @param {Env} env
187
+ * @param {object} [options]
188
+ * @param {Record<string, AuthConnectorProvider>} [options.connectors] - Provider stand-ins
189
+ * @returns {Promise<AuthMigrationsResult>}
190
+ */
191
+ export async function getAuthMigrations(
192
+ section: AuthSection,
193
+ projectConfig: AuthProjectConfig,
194
+ env: Env,
195
+ options: { connectors?: Record<string, AuthConnectorProvider> | undefined } = {},
196
+ ): Promise<AuthMigrationsResult> {
197
+ const { dialect, type, connection } = await resolveAuthDatabase(
198
+ section,
199
+ projectConfig,
200
+ env,
201
+ options.connectors,
202
+ );
203
+ const authOptions = buildAuthOptions(section, env, { database: { dialect, type } });
204
+ const migrations = await getMigrations(authOptions as Parameters<typeof getMigrations>[0]);
205
+ const sqlBlob = await migrations.compileMigrations();
206
+ const steps = splitSqlStatements(sqlBlob).map((statement) =>
207
+ classifyAuthStatement(statement, connection),
208
+ );
209
+ return {
210
+ apply: async () => {
211
+ if (steps.length > 0) {
212
+ await migrations.runMigrations();
213
+ }
214
+ },
215
+ connection,
216
+ steps,
217
+ };
218
+ }
package/src/session.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Session — the module-level session store and the `Session` state class.
3
+ *
4
+ * One store per page: the first `Session.resolve()` fetches the session through the auth client,
5
+ * later resolves reuse the in-flight/last value, and every auth action refresh notifies subscribers
6
+ * — `subscribe()` rides the runtime's existing ref machinery, so any `state` entry with
7
+ * `"$prototype": "Session"` updates live across sign-in/sign-out. Outside browsers `resolve()`
8
+ * returns null, keeping statically generated pages on the signed-out state.
9
+ */
10
+
11
+ import { getAuthClient } from "./client.ts";
12
+ import { toSessionInfo } from "./config.ts";
13
+ import type { SessionInfo } from "@jxsuite/connector/types";
14
+
15
+ export type SessionListener = (session: SessionInfo | null) => void;
16
+
17
+ let current: SessionInfo | null = null;
18
+ let pending: Promise<SessionInfo | null> | null = null;
19
+ const listeners = new Set<SessionListener>();
20
+
21
+ /** True when running in a browser (session fetching is a client-only concern). */
22
+ export function inBrowser(): boolean {
23
+ return typeof document !== "undefined" && typeof fetch === "function";
24
+ }
25
+
26
+ function notify(): void {
27
+ for (const listener of listeners) {
28
+ listener(current);
29
+ }
30
+ }
31
+
32
+ /** The last-known session (synchronous access; null when signed out or never fetched). */
33
+ export function currentSession(): SessionInfo | null {
34
+ return current;
35
+ }
36
+
37
+ /**
38
+ * Fetch the session through the auth client, update the store, and notify subscribers.
39
+ *
40
+ * @param {string} [baseUrl] - Auth route base URL; defaults to <origin>/_jx/auth
41
+ * @returns {Promise<SessionInfo | null>}
42
+ */
43
+ export async function fetchSession(baseUrl?: string): Promise<SessionInfo | null> {
44
+ const run = (async () => {
45
+ try {
46
+ const { data } = await getAuthClient(baseUrl).getSession();
47
+ current = toSessionInfo(data);
48
+ } catch {
49
+ // Fail-closed: an unreachable auth backend reads as signed out.
50
+ current = null;
51
+ }
52
+ notify();
53
+ return current;
54
+ })();
55
+ pending = run;
56
+ return run;
57
+ }
58
+
59
+ /** Refresh the store in the background (auth actions call this after every state change). */
60
+ export function refreshSession(baseUrl?: string): void {
61
+ void fetchSession(baseUrl);
62
+ }
63
+
64
+ /** Drop the session locally (sign-out) and notify subscribers. */
65
+ export function clearSession(): void {
66
+ current = null;
67
+ pending = Promise.resolve(null);
68
+ notify();
69
+ }
70
+
71
+ /**
72
+ * Subscribe to session changes.
73
+ *
74
+ * @param {SessionListener} listener
75
+ * @returns {() => void} Unsubscribe
76
+ */
77
+ export function subscribeSession(listener: SessionListener): () => void {
78
+ listeners.add(listener);
79
+ return () => listeners.delete(listener);
80
+ }
81
+
82
+ /** Reset the store (test hook). */
83
+ export function resetSessionStore(): void {
84
+ current = null;
85
+ pending = null;
86
+ listeners.clear();
87
+ }
88
+
89
+ /** Config of the Session state class. */
90
+ export interface SessionConfig {
91
+ baseUrl?: string;
92
+ [key: string]: unknown;
93
+ }
94
+
95
+ export class Session {
96
+ config: SessionConfig;
97
+
98
+ constructor(config: SessionConfig = {}) {
99
+ this.config = config;
100
+ }
101
+
102
+ /** SessionInfo for the signed-in user, or null — always null outside browsers (SSG-safe). */
103
+ async resolve(): Promise<SessionInfo | null> {
104
+ if (!inBrowser()) {
105
+ return null;
106
+ }
107
+ pending ??= fetchSession(this.config.baseUrl);
108
+ return pending;
109
+ }
110
+
111
+ /** Ride the runtime ref machinery: push every session change into the wrapping signal. */
112
+ subscribe(callback: SessionListener): void {
113
+ subscribeSession(callback);
114
+ if (pending === null && inBrowser()) {
115
+ pending = fetchSession(this.config.baseUrl);
116
+ }
117
+ }
118
+ }