@pathmx/auth 0.5.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/host.ts ADDED
@@ -0,0 +1,108 @@
1
+ import {
2
+ anonymousCredentialHost,
3
+ createCredentialHost,
4
+ type CredentialHost,
5
+ type CredentialProvider,
6
+ type PluginLog,
7
+ } from "@pathmx/core"
8
+ import type { AuthProviderFactory } from "./providers/types.ts"
9
+ import { providersFromEnv, type AuthEnvironment } from "./providers/env.ts"
10
+ import type { SqlDatabase } from "@pathmx/core"
11
+ import { developmentCredentialHost } from "./providers/development.ts"
12
+
13
+ function combinedProvider(
14
+ providers: readonly CredentialProvider[],
15
+ ): CredentialProvider {
16
+ return {
17
+ signIn: providers.find((provider) => provider.signIn)?.signIn ?? null,
18
+ async ready(directory) {
19
+ await Promise.all(providers.map((provider) => provider.ready(directory)))
20
+ },
21
+ async resolve(request) {
22
+ for (const provider of providers) {
23
+ const identity = await provider.resolve(request)
24
+ if (identity) return identity
25
+ }
26
+ },
27
+ async fetch(request, directory) {
28
+ for (const provider of providers) {
29
+ const response = await provider.fetch(request, directory)
30
+ if (response.status !== 404) return response
31
+ }
32
+ return new Response("Not Found", { status: 404 })
33
+ },
34
+ async close() {
35
+ const results = await Promise.allSettled(
36
+ providers.map((provider) => provider.close()),
37
+ )
38
+ const errors = results.flatMap((result) =>
39
+ result.status === "rejected" ? [result.reason] : [],
40
+ )
41
+ if (errors.length) {
42
+ throw new AggregateError(
43
+ errors,
44
+ "Authentication provider shutdown failed.",
45
+ )
46
+ }
47
+ },
48
+ }
49
+ }
50
+
51
+ export function productionCredentialHost(options: {
52
+ database: SqlDatabase
53
+ log: PluginLog
54
+ providers?: readonly AuthProviderFactory[]
55
+ env?: AuthEnvironment
56
+ allowAnonymous?: boolean
57
+ }) {
58
+ const factories = options.providers
59
+ const env = options.env ?? process.env
60
+ if (!factories?.length && options.allowAnonymous && !env.PATHMX_AUTH_MODE) {
61
+ return anonymousCredentialHost()
62
+ }
63
+ const providers = factories?.length
64
+ ? factories.map((provider) =>
65
+ provider({ database: options.database, log: options.log }),
66
+ )
67
+ : providersFromEnv({ database: options.database, log: options.log }, env)
68
+ return createCredentialHost(combinedProvider(providers))
69
+ }
70
+
71
+ export function runtimeCredentialHost(options: {
72
+ development: boolean
73
+ database: SqlDatabase
74
+ log: PluginLog
75
+ providers?: readonly AuthProviderFactory[]
76
+ env?: AuthEnvironment
77
+ allowAnonymous?: boolean
78
+ }) {
79
+ return options.development
80
+ ? developmentCredentialHost()
81
+ : productionCredentialHost(options)
82
+ }
83
+
84
+ export function deferredCredentialHost() {
85
+ let current: CredentialHost | undefined
86
+ const required = () => {
87
+ if (!current) throw new Error("Authentication plugin is not initialized.")
88
+ return current
89
+ }
90
+ return {
91
+ host: Object.freeze({
92
+ ready: (...args: Parameters<CredentialHost["ready"]>) =>
93
+ required().ready(...args),
94
+ resolve: (...args: Parameters<CredentialHost["resolve"]>) =>
95
+ required().resolve(...args),
96
+ fetch: (...args: Parameters<CredentialHost["fetch"]>) =>
97
+ required().fetch(...args),
98
+ async close() {
99
+ await current?.close()
100
+ current = undefined
101
+ },
102
+ }) satisfies CredentialHost,
103
+ set(host: CredentialHost) {
104
+ if (current) throw new Error("Authentication plugin already initialized.")
105
+ current = host
106
+ },
107
+ }
108
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ definePlugin,
3
+ privateResponse,
4
+ route,
5
+ type CredentialHost,
6
+ type Plugin,
7
+ } from "@pathmx/core"
8
+ import { authComponents } from "./components/index.ts"
9
+ import { registerAuthCommands } from "./commands.ts"
10
+ import { authDatabase } from "./database.ts"
11
+ import { deferredCredentialHost, runtimeCredentialHost } from "./host.ts"
12
+ import type { AuthEnvironment } from "./providers/env.ts"
13
+ import type { AuthProviderFactory } from "./providers/types.ts"
14
+
15
+ export type AuthPluginOptions = Readonly<{
16
+ providers?: readonly AuthProviderFactory[]
17
+ env?: AuthEnvironment
18
+ /** Permit an anonymous production host when no provider is configured. */
19
+ allowAnonymous?: boolean
20
+ /** Supply an already composed host for custom providers and tests. */
21
+ credentials?: CredentialHost
22
+ /** Replace the stock magic-link UI while retaining auth state and routes. */
23
+ components?: Plugin["components"]
24
+ }>
25
+
26
+ export function AuthPlugin(options: AuthPluginOptions = {}): Plugin {
27
+ const credentials = deferredCredentialHost()
28
+ if (options.credentials) credentials.set(options.credentials)
29
+ return definePlugin({
30
+ id: "auth",
31
+ name: "PathMX Auth",
32
+ database: authDatabase,
33
+ credentials: credentials.host,
34
+ components: options.components ?? authComponents,
35
+ commands(group, ctx) {
36
+ registerAuthCommands(group, ctx, options.env)
37
+ },
38
+ setup(ctx) {
39
+ if (options.credentials) return
40
+ if (!ctx.database) throw new Error("Auth database is unavailable.")
41
+ credentials.set(
42
+ runtimeCredentialHost({
43
+ development: ctx.runtime.mode === "development",
44
+ database: ctx.database,
45
+ log: ctx.log,
46
+ providers: options.providers,
47
+ env: options.env,
48
+ allowAnonymous: options.allowAnonymous,
49
+ }),
50
+ )
51
+ },
52
+ routes: [
53
+ route.all("*", async (ctx) =>
54
+ privateResponse(await ctx.handleCredentials()),
55
+ ),
56
+ ],
57
+ })
58
+ }
59
+
60
+ export { authRoutes } from "./providers/magic-link.ts"
61
+ export {
62
+ Confirm as AuthConfirm,
63
+ Session as AuthSession,
64
+ SignIn as AuthSignIn,
65
+ authComponents,
66
+ } from "./components/index.ts"
67
+ export type { AuthEnvironment } from "./providers/env.ts"
68
+ export type {
69
+ AuthProviderContext,
70
+ AuthProviderFactory,
71
+ } from "./providers/types.ts"
72
+ export type {
73
+ MagicLinkDelivery,
74
+ MagicLinkMailer,
75
+ MagicLinkProviderOptions,
76
+ } from "./providers/magic-link.ts"
77
+ export { magicLinkProvider } from "./providers/magic-link.ts"
78
+ export { AuthSetupPattern } from "./patterns/setup.ts"
79
+ export {
80
+ developmentCredentialHost,
81
+ type DevelopmentCredentialHost,
82
+ } from "./providers/development.ts"
83
+ export default AuthPlugin
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@pathmx/auth",
3
+ "description": "Authentication and account UI for PathMX applications.",
4
+ "version": "0.5.0",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pathmx/pathmx-beta.git",
9
+ "directory": "paths/auth"
10
+ },
11
+ "homepage": "https://github.com/pathmx/pathmx-beta#readme",
12
+ "bugs": "https://github.com/pathmx/pathmx-beta/issues",
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "registry": "https://registry.npmjs.org/",
16
+ "tag": "beta"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": "./index.plugin.ts",
21
+ "./providers": "./providers/index.ts"
22
+ },
23
+ "engines": {
24
+ "bun": ">=1.4.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@pathmx/core": "^0.5.0",
28
+ "react": ">=19.0.0",
29
+ "react-dom": ">=19.0.0"
30
+ },
31
+ "dependencies": {
32
+ "@pathmx/react": "0.5.0",
33
+ "better-auth": "1.6.29"
34
+ }
35
+ }
@@ -0,0 +1,30 @@
1
+ import { definePattern } from "@pathmx/core"
2
+
3
+ export const AuthSetupPattern = definePattern({
4
+ id: "auth:setup",
5
+ description: "Add the stock authentication pages and environment example.",
6
+ generate() {
7
+ return [
8
+ {
9
+ root: "paths",
10
+ path: "sign-in/index.page.md",
11
+ content: "# Sign in\n\n<x-auth-sign-in />\n",
12
+ },
13
+ {
14
+ root: "paths",
15
+ path: "auth/confirm/index.page.md",
16
+ content: "# Confirm sign in\n\n<x-auth-confirm />\n",
17
+ },
18
+ {
19
+ root: "project",
20
+ path: ".env.example",
21
+ content: `PATHMX_AUTH_MODE=magic-link
22
+ PATHMX_AUTH_ORIGIN=http://localhost:3000
23
+ PATHMX_AUTH_SECRET=
24
+ PATHMX_AUTH_EMAIL_FROM=PathMX <auth@example.com>
25
+ RESEND_API_KEY=
26
+ `,
27
+ },
28
+ ] as const
29
+ },
30
+ })
@@ -0,0 +1,96 @@
1
+ import type { SqlDatabase, SqlValue } from "@pathmx/core"
2
+
3
+ function sqlValues(values: readonly unknown[]): SqlValue[] {
4
+ return values.map((value) => {
5
+ if (
6
+ value === null ||
7
+ typeof value === "string" ||
8
+ typeof value === "number" ||
9
+ typeof value === "bigint" ||
10
+ value instanceof Uint8Array
11
+ ) {
12
+ return value
13
+ }
14
+ if (typeof value === "boolean") return value ? 1 : 0
15
+ throw new Error(
16
+ `Unsupported authentication database value: ${typeof value}`,
17
+ )
18
+ })
19
+ }
20
+
21
+ function mutates(sql: string) {
22
+ return /^\s*(?:insert|update|delete|replace)\b/i.test(sql)
23
+ }
24
+
25
+ class PathMXD1Statement {
26
+ private values: SqlValue[] = []
27
+
28
+ constructor(
29
+ private database: SqlDatabase,
30
+ private sql: string,
31
+ ) {}
32
+
33
+ bind(...values: unknown[]) {
34
+ this.values = sqlValues(values)
35
+ return this
36
+ }
37
+
38
+ private async execute(database: SqlDatabase) {
39
+ const mutation = mutates(this.sql)
40
+ if (mutation && !/\breturning\b/i.test(this.sql)) {
41
+ const result = await database.run(this.sql, this.values)
42
+ return {
43
+ success: true,
44
+ results: [],
45
+ meta: {
46
+ changes: result.changes,
47
+ last_row_id: result.lastInsertRowId,
48
+ },
49
+ }
50
+ }
51
+ const results = await database.all(this.sql, this.values)
52
+ if (!mutation) {
53
+ return { success: true, results, meta: { changes: 0 } }
54
+ }
55
+ const meta = await database.get<{ changes: number; lastRowId: number }>(
56
+ "SELECT changes() AS changes, last_insert_rowid() AS lastRowId",
57
+ )
58
+ return {
59
+ success: true,
60
+ results,
61
+ meta: {
62
+ changes: meta?.changes ?? 0,
63
+ last_row_id: meta?.lastRowId,
64
+ },
65
+ }
66
+ }
67
+
68
+ all() {
69
+ return mutates(this.sql) && /\breturning\b/i.test(this.sql)
70
+ ? this.database.transaction((database) => this.execute(database))
71
+ : this.execute(this.database)
72
+ }
73
+
74
+ executeIn(database: SqlDatabase) {
75
+ return this.execute(database)
76
+ }
77
+ }
78
+
79
+ /** Adapt PathMX's portable SQL contract to Better Auth's supported D1 shape. */
80
+ export function betterAuthDatabase(database: SqlDatabase) {
81
+ return {
82
+ prepare: (sql: string) => new PathMXD1Statement(database, sql),
83
+ batch: (statements: PathMXD1Statement[]) =>
84
+ database.transaction(async (transaction) => {
85
+ const results = []
86
+ for (const statement of statements) {
87
+ results.push(await statement.executeIn(transaction))
88
+ }
89
+ return results
90
+ }),
91
+ async exec(sql: string) {
92
+ await database.exec(sql)
93
+ return { count: 1, duration: 0 }
94
+ },
95
+ }
96
+ }
@@ -0,0 +1,144 @@
1
+ import type { CredentialHost, CredentialSession, UserActor } from "@pathmx/core"
2
+
3
+ const COOKIE = "pathmx-dev-session"
4
+
5
+ function escapeHtml(value: string) {
6
+ return value
7
+ .replaceAll("&", "&amp;")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll('"', "&quot;")
11
+ }
12
+
13
+ function cookieValue(header: string | null) {
14
+ if (!header) return
15
+ for (const part of header.split(";")) {
16
+ const equals = part.indexOf("=")
17
+ if (equals === -1 || part.slice(0, equals).trim() !== COOKIE) continue
18
+ try {
19
+ return decodeURIComponent(part.slice(equals + 1).trim())
20
+ } catch {
21
+ return
22
+ }
23
+ }
24
+ }
25
+
26
+ function loopback(hostname: string) {
27
+ return (
28
+ hostname === "localhost" ||
29
+ hostname === "::1" ||
30
+ hostname === "[::1]" ||
31
+ hostname.startsWith("127.")
32
+ )
33
+ }
34
+
35
+ function devRequest(request: Request) {
36
+ const url = new URL(request.url)
37
+ if (!loopback(url.hostname)) return
38
+ const origin = request.headers.get("origin")
39
+ if (origin && origin !== url.origin) return
40
+ return url
41
+ }
42
+
43
+ function authenticated(actor: UserActor): CredentialSession {
44
+ return {
45
+ type: "authenticated",
46
+ actor,
47
+ signOut: { method: "post", href: "/api/auth/dev/sign-out" },
48
+ }
49
+ }
50
+
51
+ export type DevelopmentCredentialHost = CredentialHost &
52
+ Readonly<{
53
+ createSession(actorId: string): string
54
+ cookie(sessionId: string): string
55
+ }>
56
+
57
+ export function developmentCredentialHost(): DevelopmentCredentialHost {
58
+ const sessions = new Map<string, string>()
59
+ const createSession = (actorId: string) => {
60
+ const sessionId = crypto.randomUUID()
61
+ sessions.set(sessionId, actorId)
62
+ return sessionId
63
+ }
64
+ const cookie = (sessionId: string) =>
65
+ `${COOKIE}=${encodeURIComponent(sessionId)}`
66
+
67
+ return {
68
+ async ready() {},
69
+ createSession,
70
+ cookie,
71
+ async resolve(request, actors) {
72
+ const url = devRequest(request)
73
+ if (!url) return { type: "anonymous", signIn: null }
74
+ const sessionId = cookieValue(request.headers.get("cookie"))
75
+ const actorId = sessionId ? sessions.get(sessionId) : undefined
76
+ const actor = actorId ? actors.findActorById(actorId) : undefined
77
+ return actor?.type === "user"
78
+ ? authenticated(actor)
79
+ : {
80
+ type: "anonymous",
81
+ signIn: { method: "get", href: "/api/auth/dev" },
82
+ }
83
+ },
84
+ async fetch(request, actors) {
85
+ const url = devRequest(request)
86
+ if (!url) return new Response("Not Found", { status: 404 })
87
+ if (request.method === "GET" && url.pathname === "/api/auth/dev") {
88
+ const users = actors
89
+ .all()
90
+ .filter((actor): actor is UserActor => actor.type === "user")
91
+ .map(
92
+ (actor) =>
93
+ `<form method="post" action="/api/auth/dev/session"><input type="hidden" name="actor" value="${escapeHtml(actor.id)}"><button type="submit">${escapeHtml(actor.profile.name)}</button></form>`,
94
+ )
95
+ .join("\n")
96
+ return new Response(
97
+ `<!doctype html><title>Choose Actor</title>${users}`,
98
+ {
99
+ headers: { "Content-Type": "text/html; charset=utf-8" },
100
+ },
101
+ )
102
+ }
103
+ if (
104
+ request.method === "POST" &&
105
+ url.pathname === "/api/auth/dev/session"
106
+ ) {
107
+ const actorId = (await request.formData()).get("actor")
108
+ const actor =
109
+ typeof actorId === "string"
110
+ ? actors.findActorById(actorId)
111
+ : undefined
112
+ if (!actor || actor.type !== "user") {
113
+ return new Response("Unknown Actor", { status: 400 })
114
+ }
115
+ const sessionId = createSession(actor.id)
116
+ return new Response(null, {
117
+ status: 303,
118
+ headers: {
119
+ Location: "/",
120
+ "Set-Cookie": `${cookie(sessionId)}; Path=/; HttpOnly; SameSite=Lax`,
121
+ },
122
+ })
123
+ }
124
+ if (
125
+ request.method === "POST" &&
126
+ url.pathname === "/api/auth/dev/sign-out"
127
+ ) {
128
+ const sessionId = cookieValue(request.headers.get("cookie"))
129
+ if (sessionId) sessions.delete(sessionId)
130
+ return new Response(null, {
131
+ status: 303,
132
+ headers: {
133
+ Location: "/",
134
+ "Set-Cookie": `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
135
+ },
136
+ })
137
+ }
138
+ return new Response("Not Found", { status: 404 })
139
+ },
140
+ async close() {
141
+ sessions.clear()
142
+ },
143
+ }
144
+ }
@@ -0,0 +1,54 @@
1
+ import { magicLinkProvider, type MagicLinkMailer } from "./magic-link.ts"
2
+ import type { AuthProviderContext, AuthProviderFactory } from "./types.ts"
3
+
4
+ export type AuthEnvironment = Readonly<Record<string, string | undefined>>
5
+
6
+ function required(env: AuthEnvironment, name: string) {
7
+ const value = env[name]?.trim()
8
+ if (!value) throw new Error(`${name} is required for magic-link auth.`)
9
+ return value
10
+ }
11
+
12
+ function resendMailer(apiKey: string, from: string): MagicLinkMailer {
13
+ return {
14
+ async send({ email, confirmUrl, expiresAt }) {
15
+ const response = await fetch("https://api.resend.com/emails", {
16
+ method: "POST",
17
+ headers: {
18
+ Authorization: `Bearer ${apiKey}`,
19
+ "Content-Type": "application/json",
20
+ },
21
+ body: JSON.stringify({
22
+ from,
23
+ to: [email],
24
+ subject: "Sign in to PathMX",
25
+ text: `Confirm your PathMX sign in:\n\n${confirmUrl}\n\nThis link expires at ${expiresAt.toISOString()}.`,
26
+ }),
27
+ })
28
+ if (!response.ok) throw new Error("Magic-link delivery failed.")
29
+ },
30
+ }
31
+ }
32
+
33
+ export function providersFromEnv(
34
+ context: AuthProviderContext,
35
+ env: AuthEnvironment = process.env,
36
+ ): ReturnType<AuthProviderFactory>[] {
37
+ const mode = required(env, "PATHMX_AUTH_MODE")
38
+ if (mode !== "magic-link") {
39
+ throw new Error("PATHMX_AUTH_MODE must be magic-link.")
40
+ }
41
+ const secret = required(env, "PATHMX_AUTH_SECRET")
42
+ if (secret.length < 32) {
43
+ throw new Error("PATHMX_AUTH_SECRET must be at least 32 characters.")
44
+ }
45
+ const provider = magicLinkProvider({
46
+ origin: required(env, "PATHMX_AUTH_ORIGIN"),
47
+ secret,
48
+ mailer: resendMailer(
49
+ required(env, "RESEND_API_KEY"),
50
+ required(env, "PATHMX_AUTH_EMAIL_FROM"),
51
+ ),
52
+ })
53
+ return [provider(context)]
54
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ authRoutes,
3
+ magicLinkProvider,
4
+ type MagicLinkDelivery,
5
+ type MagicLinkMailer,
6
+ type MagicLinkProviderOptions,
7
+ } from "./magic-link.ts"
8
+ export type { AuthProviderContext, AuthProviderFactory } from "./types.ts"
9
+ export {
10
+ developmentCredentialHost,
11
+ type DevelopmentCredentialHost,
12
+ } from "./development.ts"