@spawn-llc/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.
package/IDENTITY.md ADDED
@@ -0,0 +1,60 @@
1
+ # Spawn identity
2
+
3
+ **We use one WorkOS project, named "Spawn," as the identity backend for the entire Spawn suite.**
4
+ An account on one Spawn product is an account on all of them — users are shared. This document is
5
+ the canonical reference for how Spawn auth works.
6
+
7
+ ## Principles
8
+
9
+ 1. **One project.** A single WorkOS project/organization (the AuthKit application is named
10
+ **"Spawn"**) backs every app — one shared user pool. Each app is a client of the same project.
11
+ 2. **WorkOS is an invisible engine.** It provides password security, OAuth, sessions, MFA, SSO, and
12
+ compliance (free to 1M MAU). No app renders a WorkOS page or exposes a WorkOS type. The
13
+ developer-facing API is `@spawn-llc/auth`; the user-facing UI is `@spawn-llc/design-system`.
14
+ 3. **Own the UI, rent the engine.** Screens are ours (headless, from the design system). Auth is
15
+ WorkOS's. We never store passwords.
16
+ 4. **One choke point.** All identity goes through this package. Swapping providers would touch only
17
+ here.
18
+
19
+ ## Architecture
20
+
21
+ ```
22
+ @spawn-llc/design-system → <SignIn> <SignUp> <VerifyEmail> <ResetPassword> … (the UI)
23
+ @spawn-llc/auth → session seam + headless flows + IdentityGateway (the wiring)
24
+ └── WorkOS (User Management API) (the engine)
25
+ app → own /login /signup /verify /reset routes rendering the DS screens,
26
+ wired to @spawn-llc/auth; middleware = authProxy(); /callback = handleCallback()
27
+ ```
28
+
29
+ - **Sessions & flows:** `@spawn-llc/auth/nextjs` — `currentUser`/`requireUser`/`session`/`signOut`,
30
+ `authProxy`, `handleCallback`, and headless `signInWithPassword`/`signUp`/`verifyEmail`/
31
+ `requestPasswordReset`/`resetPassword`/`startOAuth` (each calls WorkOS then seals the cookie).
32
+ - **Tenancy:** `IdentityGateway` (core) — WorkOS organizations, memberships, roles
33
+ (`owner`/`admin`/`member`), invitations. Apps keep their own org row keyed by the WorkOS org id.
34
+ - **Policy:** `@spawn-llc/auth/config` (edge-safe) — `isWorkosConfigured`, `isAllowedEmail`
35
+ (optional domain gate for internal tools), `isPublicPath`.
36
+
37
+ ## The "Spawn" WorkOS project
38
+
39
+ - Team: **Spawn** · Environments: **Staging** (`environment_01KXS4E6ENF6ANWMAPCHJVEN27`) and
40
+ **Production** (`environment_01KXS4E74YNNKSBGC2J06YKCDH`).
41
+ - AuthKit application **"Spawn"** — Staging `app_01KXS4E74NTVBDEMPQFY32YGAC`
42
+ (client `client_01KXS4E6XDET3JSD7H8PTEAKBZ`), Production `app_01KXS4E7K3QZ9A7PDFHD841SNS`
43
+ (client `client_01KXS4E79KKPHTSCJWAEM3CJYR`).
44
+ - Roles: `owner`, `admin`, `member`. Branding: light, Geist, Spawn logo (see the audit repo's
45
+ `config/authkit-branding.json`).
46
+ - **Emails: WorkOS-sent for now** (verification / reset / invite). Owning branded email is a
47
+ deferred roadmap item.
48
+
49
+ ## Add auth to a new Spawn app
50
+
51
+ 1. `pnpm add @spawn-llc/auth @spawn-llc/design-system`.
52
+ 2. Env from the "Spawn" application: `WORKOS_API_KEY`, `WORKOS_CLIENT_ID`, `WORKOS_COOKIE_PASSWORD`,
53
+ `NEXT_PUBLIC_WORKOS_REDIRECT_URI=<app>/callback`; add that redirect URI in the WorkOS dashboard.
54
+ 3. `proxy.ts` → `export default authProxy()`. `app/callback/route.ts` → `export const GET =
55
+ handleCallback()`.
56
+ 4. Build `/login`, `/signup`, `/verify`, `/reset` rendering the DS screens, wired to the headless
57
+ flows via `"use server"` actions.
58
+ 5. Internal-only tool? Set `ALLOWED_EMAIL_DOMAINS=spawnpartners.com`.
59
+
60
+ Because every app points at the same "Spawn" project, the user is the same everywhere.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @spawn-llc/auth
2
+
3
+ **Spawn shared identity.** One WorkOS project (named **"Spawn"**) backs auth for the entire Spawn
4
+ suite — sign up for one Spawn product and you have an account on all of them. This package is the
5
+ **only** place that touches WorkOS: apps consume a Spawn-shaped API (`signIn`, `signUp`,
6
+ `currentUser`, …) and never see a WorkOS type. WorkOS is an invisible engine (password security,
7
+ OAuth, sessions, MFA, SSO, compliance — free to 1M MAU); the **UI is ours** (rendered from
8
+ `@spawn-llc/design-system`). See [`IDENTITY.md`](./IDENTITY.md) for the architecture.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add @spawn-llc/auth
14
+ ```
15
+
16
+ Env (from the WorkOS "Spawn" application): `WORKOS_API_KEY`, `WORKOS_CLIENT_ID`,
17
+ `WORKOS_COOKIE_PASSWORD` (32+ chars), `NEXT_PUBLIC_WORKOS_REDIRECT_URI` (the app's `/callback`).
18
+ Optional: `ALLOWED_EMAIL_DOMAINS` (comma-separated — restrict to e.g. `spawnpartners.com` for
19
+ internal tools), `AUTH_PUBLIC_PATHS`.
20
+
21
+ ## Subpaths
22
+
23
+ - **`@spawn-llc/auth`** — core, edge-safe: the `IdentityGateway` (orgs / members / roles / invites) +
24
+ `createIdentityGateway()`, domain types (`Role`, `Session`, `Member`, `Invite`), and policy
25
+ (`isWorkosConfigured`, `isAllowedEmail`, `isPublicPath`).
26
+ - **`@spawn-llc/auth/config`** — the edge-safe policy alone (import from middleware; no SDK).
27
+ - **`@spawn-llc/auth/nextjs`** — the Next server surface: the session seam
28
+ (`currentUser` / `requireUser` / `session` / `signOut` / `switchOrganization`), `authProxy()`,
29
+ `handleCallback()`, and the headless flows (`signInWithPassword`, `signUp`, `verifyEmail`,
30
+ `requestPasswordReset`, `resetPassword`, `startOAuth`).
31
+
32
+ ## Wire an app (Next.js)
33
+
34
+ ```ts
35
+ // proxy.ts (gate every route)
36
+ import { authProxy } from "@spawn-llc/auth/nextjs";
37
+ export default authProxy();
38
+ export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };
39
+ ```
40
+
41
+ ```ts
42
+ // app/callback/route.ts (social login round-trip)
43
+ import { handleCallback } from "@spawn-llc/auth/nextjs";
44
+ export const GET = handleCallback({ returnPathname: "/" });
45
+ ```
46
+
47
+ ```tsx
48
+ // app/login/page.tsx — render the DS <SignIn>, wire it to the headless flow
49
+ import { SignIn } from "@spawn-llc/design-system/components/ui/sign-in";
50
+ import { signInWithPassword, startOAuth } from "@spawn-llc/auth/nextjs";
51
+ // (call these from "use server" actions; redirect on { status: "ok" })
52
+ ```
53
+
54
+ Releases are tag-triggered — see [`RELEASING.md`](./RELEASING.md).
@@ -0,0 +1,32 @@
1
+ // src/config.ts
2
+ var DEFAULT_PUBLIC_PATHS = ["/login", "/signup", "/verify", "/reset", "/callback"];
3
+ function publicPaths() {
4
+ const extra = (process.env.AUTH_PUBLIC_PATHS ?? "").split(",").map((p) => p.trim()).filter(Boolean);
5
+ return [...DEFAULT_PUBLIC_PATHS, ...extra];
6
+ }
7
+ function isPublicPath(pathname, paths = publicPaths()) {
8
+ return paths.some((p) => pathname === p || pathname.startsWith(`${p}/`));
9
+ }
10
+ function isWorkosConfigured() {
11
+ return Boolean(
12
+ process.env.WORKOS_API_KEY && (process.env.WORKOS_CLIENT_ID || process.env.NEXT_PUBLIC_WORKOS_CLIENT_ID) && process.env.WORKOS_COOKIE_PASSWORD
13
+ );
14
+ }
15
+ function allowedEmailDomains() {
16
+ const raw = process.env.ALLOWED_EMAIL_DOMAINS;
17
+ return (raw ? raw.split(",") : []).map((d) => d.trim().toLowerCase().replace(/^@/, "")).filter(Boolean);
18
+ }
19
+ function isAllowedEmail(email) {
20
+ const domains = allowedEmailDomains();
21
+ if (domains.length === 0) return true;
22
+ if (!email) return false;
23
+ const domain = email.trim().toLowerCase().split("@")[1];
24
+ return domain ? domains.includes(domain) : false;
25
+ }
26
+ function workosClientId() {
27
+ const id = process.env.WORKOS_CLIENT_ID || process.env.NEXT_PUBLIC_WORKOS_CLIENT_ID;
28
+ if (!id) throw new Error("WORKOS_CLIENT_ID is not set");
29
+ return id;
30
+ }
31
+
32
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Edge-safe auth policy — imported by app middleware. NO framework or SDK imports here
3
+ * (middleware may run on the edge runtime). This is the one choke point for "is auth configured"
4
+ * and "who is allowed"; generalized from admin's `@admin/auth` config so every Spawn app shares it.
5
+ */
6
+ /** Default paths reachable without a session; apps extend via `publicPaths()`. */
7
+ declare const DEFAULT_PUBLIC_PATHS: readonly ["/login", "/signup", "/verify", "/reset", "/callback"];
8
+ /** The public paths for this app: the defaults plus anything in `AUTH_PUBLIC_PATHS` (comma-sep). */
9
+ declare function publicPaths(): string[];
10
+ declare function isPublicPath(pathname: string, paths?: string[]): boolean;
11
+ /**
12
+ * WorkOS (the engine) is "configured" once its three required secrets are present. When it is NOT
13
+ * configured, the seam fails CLOSED (no session → redirect to sign-in) rather than letting requests
14
+ * through. `WORKOS_CLIENT_ID` is public (`NEXT_PUBLIC_WORKOS_CLIENT_ID` also accepted).
15
+ */
16
+ declare function isWorkosConfigured(): boolean;
17
+ /**
18
+ * Optional email-domain gate. Off by default (customer products let anyone sign up). Set
19
+ * `ALLOWED_EMAIL_DOMAINS` (comma-separated) to restrict — e.g. internal tools gate to
20
+ * `spawnpartners.com`. Returns true (allowed) when no gate is configured.
21
+ */
22
+ declare function allowedEmailDomains(): string[];
23
+ declare function isAllowedEmail(email: string | null | undefined): boolean;
24
+ /** The WorkOS client id, from either the server or the public var. */
25
+ declare function workosClientId(): string;
26
+
27
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId };
package/dist/config.js ADDED
@@ -0,0 +1 @@
1
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId } from './chunk-SISIAS4C.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Spawn identity domain types — framework-free, shared by every app. WorkOS is the engine behind
3
+ * these, but nothing here imports a WorkOS type: apps speak Spawn's vocabulary, never the vendor's.
4
+ */
5
+ /** A member's role in an organization. Maps 1:1 to a WorkOS environment role slug. */
6
+ type Role = "owner" | "admin" | "member";
7
+ /** A signed-in person. */
8
+ interface User {
9
+ id: string;
10
+ email: string;
11
+ name: string;
12
+ }
13
+ /** The Spawn session — the provider-neutral shape apps consume (never a WorkOS `UserInfo`). */
14
+ interface Session {
15
+ user: User;
16
+ /** The active organization for this session, if the app is org-scoped. */
17
+ organizationId?: string;
18
+ /** The user's role in the active organization, if any. */
19
+ role?: Role;
20
+ }
21
+ /** An organization a user belongs to, with that user's role — for org switchers. */
22
+ interface OrgMembership {
23
+ orgId: string;
24
+ role: Role;
25
+ }
26
+ /** A member row for team settings: the membership plus the user it belongs to. */
27
+ interface Member {
28
+ user: User;
29
+ role: Role;
30
+ createdAt: string;
31
+ }
32
+ /** A pending team invitation. */
33
+ interface Invite {
34
+ id: string;
35
+ email: string;
36
+ role: Role;
37
+ state: "pending" | "accepted" | "expired" | "revoked";
38
+ createdAt: string;
39
+ }
40
+
41
+ export type { Invite as I, Member as M, OrgMembership as O, Role as R, Session as S, User as U };
@@ -0,0 +1,111 @@
1
+ import { R as Role } from './domain-CmEhGW2N.js';
2
+ export { I as Invite, M as Member, O as OrgMembership, S as Session, U as User } from './domain-CmEhGW2N.js';
3
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId } from './config.js';
4
+
5
+ /**
6
+ * The identity/tenancy port. WorkOS owns organizations, memberships (+ roles) and invitations; this
7
+ * is the framework-free interface apps talk to. The real impl (`WorkOSIdentityGateway`) wraps
8
+ * `@workos-inc/node`; tests inject an in-memory fake. Apps store their own org row keyed by the
9
+ * WorkOS organization id this gateway mints.
10
+ */
11
+ interface IdentityGateway {
12
+ /** Create a WorkOS organization; returns its id (use as the app's org PK). */
13
+ createOrganization(input: {
14
+ name: string;
15
+ }): Promise<{
16
+ id: string;
17
+ }>;
18
+ /** Delete a WorkOS organization (best-effort cleanup). */
19
+ deleteOrganization(id: string): Promise<void>;
20
+ /** Add a user to an org with a role (idempotent — re-adding updates the role). */
21
+ addMembership(input: {
22
+ userId: string;
23
+ orgId: string;
24
+ role: Role;
25
+ }): Promise<void>;
26
+ /** The caller's role in an org, or null if not a member (the authorization gate). */
27
+ getMembership(userId: string, orgId: string): Promise<{
28
+ role: Role;
29
+ } | null>;
30
+ /** Orgs the user belongs to, each with the user's role. */
31
+ listUserMemberships(userId: string): Promise<{
32
+ orgId: string;
33
+ role: Role;
34
+ }[]>;
35
+ /** Members of an org (for team settings). */
36
+ listMembers(orgId: string): Promise<IdentityMember[]>;
37
+ updateMembershipRole(orgId: string, userId: string, role: Role): Promise<void>;
38
+ removeMembership(orgId: string, userId: string): Promise<void>;
39
+ /** Send a WorkOS invitation email joining the invitee to the org with a role. */
40
+ inviteUser(input: {
41
+ email: string;
42
+ orgId: string;
43
+ role: Role;
44
+ }): Promise<IdentityInvite>;
45
+ listInvites(orgId: string): Promise<IdentityInvite[]>;
46
+ revokeInvite(inviteId: string): Promise<void>;
47
+ }
48
+ /** A member of an org, resolved from a WorkOS organization membership + its user. */
49
+ interface IdentityMember {
50
+ user: {
51
+ id: string;
52
+ email: string;
53
+ name: string;
54
+ };
55
+ role: Role;
56
+ createdAt: string;
57
+ }
58
+ /** A WorkOS invitation surfaced to the team settings UI. */
59
+ interface IdentityInvite {
60
+ id: string;
61
+ email: string;
62
+ role: Role;
63
+ state: "pending" | "accepted" | "expired" | "revoked";
64
+ createdAt: string;
65
+ }
66
+
67
+ /**
68
+ * The real {@link IdentityGateway}, backed by WorkOS via `@workos-inc/node`. Our `Role` values
69
+ * (`owner|admin|member`) are used verbatim as WorkOS role slugs — the "Spawn" environment defines
70
+ * matching roles. This is the ONLY file that touches WorkOS org/membership/invite APIs.
71
+ */
72
+ declare class WorkOSIdentityGateway implements IdentityGateway {
73
+ private readonly apiKey;
74
+ private clientPromise;
75
+ constructor(apiKey: string);
76
+ private client;
77
+ createOrganization(input: {
78
+ name: string;
79
+ }): Promise<{
80
+ id: string;
81
+ }>;
82
+ deleteOrganization(id: string): Promise<void>;
83
+ addMembership(input: {
84
+ userId: string;
85
+ orgId: string;
86
+ role: Role;
87
+ }): Promise<void>;
88
+ getMembership(userId: string, orgId: string): Promise<{
89
+ role: Role;
90
+ } | null>;
91
+ listUserMemberships(userId: string): Promise<{
92
+ orgId: string;
93
+ role: Role;
94
+ }[]>;
95
+ listMembers(orgId: string): Promise<IdentityMember[]>;
96
+ updateMembershipRole(orgId: string, userId: string, role: Role): Promise<void>;
97
+ removeMembership(orgId: string, userId: string): Promise<void>;
98
+ inviteUser(input: {
99
+ email: string;
100
+ orgId: string;
101
+ role: Role;
102
+ }): Promise<IdentityInvite>;
103
+ listInvites(orgId: string): Promise<IdentityInvite[]>;
104
+ revokeInvite(inviteId: string): Promise<void>;
105
+ /** Find the caller's (active) membership row in an org, or undefined. */
106
+ private findMembership;
107
+ }
108
+ /** Composition-root factory. Throws unless WORKOS_API_KEY is set (no offline fallback). */
109
+ declare function createIdentityGateway(): IdentityGateway;
110
+
111
+ export { type IdentityGateway, type IdentityInvite, type IdentityMember, Role, WorkOSIdentityGateway, createIdentityGateway };
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId } from './chunk-SISIAS4C.js';
2
+
3
+ // src/identity/workos-gateway.ts
4
+ var ROLES = ["owner", "admin", "member"];
5
+ function toRole(slug) {
6
+ return slug && ROLES.includes(slug) ? slug : "member";
7
+ }
8
+ var WorkOSIdentityGateway = class {
9
+ constructor(apiKey) {
10
+ this.apiKey = apiKey;
11
+ }
12
+ apiKey;
13
+ clientPromise = null;
14
+ client() {
15
+ if (!this.clientPromise) {
16
+ this.clientPromise = import('@workos-inc/node').then(({ WorkOS }) => new WorkOS(this.apiKey));
17
+ }
18
+ return this.clientPromise;
19
+ }
20
+ async createOrganization(input) {
21
+ const workos = await this.client();
22
+ const org = await workos.organizations.createOrganization({ name: input.name });
23
+ return { id: org.id };
24
+ }
25
+ async deleteOrganization(id) {
26
+ const workos = await this.client();
27
+ await workos.organizations.deleteOrganization(id);
28
+ }
29
+ async addMembership(input) {
30
+ const workos = await this.client();
31
+ const existing = await this.findMembership(input.userId, input.orgId);
32
+ if (existing) {
33
+ await workos.userManagement.updateOrganizationMembership(existing.id, {
34
+ roleSlug: input.role
35
+ });
36
+ return;
37
+ }
38
+ await workos.userManagement.createOrganizationMembership({
39
+ userId: input.userId,
40
+ organizationId: input.orgId,
41
+ roleSlug: input.role
42
+ });
43
+ }
44
+ async getMembership(userId, orgId) {
45
+ const m = await this.findMembership(userId, orgId);
46
+ return m ? { role: toRole(m.role?.slug) } : null;
47
+ }
48
+ async listUserMemberships(userId) {
49
+ const workos = await this.client();
50
+ const res = await workos.userManagement.listOrganizationMemberships({
51
+ userId,
52
+ statuses: ["active"],
53
+ limit: 100
54
+ });
55
+ return res.data.map((m) => ({ orgId: m.organizationId, role: toRole(m.role?.slug) }));
56
+ }
57
+ async listMembers(orgId) {
58
+ const workos = await this.client();
59
+ const res = await workos.userManagement.listOrganizationMemberships({
60
+ organizationId: orgId,
61
+ limit: 100
62
+ });
63
+ return Promise.all(
64
+ res.data.map(async (m) => {
65
+ const user = await workos.userManagement.getUser(m.userId);
66
+ const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email;
67
+ return {
68
+ user: { id: user.id, email: user.email, name },
69
+ role: toRole(m.role?.slug),
70
+ createdAt: m.createdAt
71
+ };
72
+ })
73
+ );
74
+ }
75
+ async updateMembershipRole(orgId, userId, role) {
76
+ const workos = await this.client();
77
+ const m = await this.findMembership(userId, orgId);
78
+ if (m) await workos.userManagement.updateOrganizationMembership(m.id, { roleSlug: role });
79
+ }
80
+ async removeMembership(orgId, userId) {
81
+ const workos = await this.client();
82
+ const m = await this.findMembership(userId, orgId);
83
+ if (m) await workos.userManagement.deleteOrganizationMembership(m.id);
84
+ }
85
+ async inviteUser(input) {
86
+ const workos = await this.client();
87
+ const inv = await workos.userManagement.sendInvitation({
88
+ email: input.email,
89
+ organizationId: input.orgId,
90
+ roleSlug: input.role
91
+ });
92
+ return {
93
+ id: inv.id,
94
+ email: inv.email,
95
+ role: input.role,
96
+ state: inv.state,
97
+ createdAt: inv.createdAt
98
+ };
99
+ }
100
+ async listInvites(orgId) {
101
+ const workos = await this.client();
102
+ const res = await workos.userManagement.listInvitations({ organizationId: orgId, limit: 100 });
103
+ return res.data.filter((i) => i.state === "pending").map((i) => ({
104
+ id: i.id,
105
+ email: i.email,
106
+ role: "member",
107
+ state: i.state,
108
+ createdAt: i.createdAt
109
+ }));
110
+ }
111
+ async revokeInvite(inviteId) {
112
+ const workos = await this.client();
113
+ await workos.userManagement.revokeInvitation(inviteId);
114
+ }
115
+ /** Find the caller's (active) membership row in an org, or undefined. */
116
+ async findMembership(userId, orgId) {
117
+ const workos = await this.client();
118
+ const res = await workos.userManagement.listOrganizationMemberships({
119
+ userId,
120
+ organizationId: orgId,
121
+ limit: 1
122
+ });
123
+ return res.data[0];
124
+ }
125
+ };
126
+ function createIdentityGateway() {
127
+ const apiKey = process.env.WORKOS_API_KEY;
128
+ if (!apiKey) {
129
+ throw new Error(
130
+ "WORKOS_API_KEY is required \u2014 Spawn identity has no offline fallback (WorkOS is the engine)."
131
+ );
132
+ }
133
+ return new WorkOSIdentityGateway(apiKey);
134
+ }
135
+
136
+ export { WorkOSIdentityGateway, createIdentityGateway };
@@ -0,0 +1,114 @@
1
+ import { U as User, S as Session } from '../domain-CmEhGW2N.js';
2
+ import * as next_server from 'next/server';
3
+
4
+ /**
5
+ * The Spawn session seam for Next apps. ONE choke point: app code only ever calls `currentUser()` /
6
+ * `requireUser()` / `session()` / `signOut()` — nothing outside this package knows it's WorkOS.
7
+ * Generalized from admin's `@admin/auth` + audit's `lib/session.ts`.
8
+ */
9
+ /** The signed-in Spawn user, or null. Applies the optional email-domain gate. */
10
+ declare function currentUser(): Promise<User | null>;
11
+ /** The signed-in user; redirects to `/login` when absent. */
12
+ declare function requireUser(redirectTo?: string): Promise<User>;
13
+ /** The full Spawn session (user + active org + role), or null. */
14
+ declare function session(): Promise<Session | null>;
15
+ /** The signed-in email BEFORE the domain gate, if any — lets a login page show a wrong-domain denial. */
16
+ declare function rejectedEmail(): Promise<string | null>;
17
+ /** Sign out of the current session (clears the cookie, redirects to WorkOS logout → the app). */
18
+ declare function signOut(): Promise<void>;
19
+ /** Switch the active organization for a multi-org session. */
20
+ declare function switchOrganization(organizationId: string): Promise<void>;
21
+ /** A display name for account UI: full name if set, else email. */
22
+ declare function userDisplayName(user: User): string;
23
+
24
+ /**
25
+ * The app middleware/proxy. Because Spawn renders its OWN auth screens (headless), this only
26
+ * **refreshes** the WorkOS session cookie on each request — it does NOT redirect to any hosted page.
27
+ * Route protection is done in-app: authed layouts call `requireUser()`, which redirects
28
+ * unauthenticated visitors to `/login` (a Spawn screen). Use as the app's `proxy.ts` default export:
29
+ *
30
+ * export default authProxy();
31
+ * export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };
32
+ *
33
+ * Pass `{ enforce: true }` only if you want middleware-level gating to WorkOS's hosted UI instead
34
+ * of your own screens (not the Spawn default).
35
+ */
36
+ declare function authProxy(options?: {
37
+ enforce?: boolean;
38
+ unauthenticatedPaths?: string[];
39
+ }): next_server.NextMiddleware;
40
+
41
+ /**
42
+ * The OAuth callback handler for the app's `/callback` route:
43
+ *
44
+ * export const GET = handleCallback({ returnPathname: "/" });
45
+ *
46
+ * Exchanges the WorkOS code, seals the Spawn session cookie, and forwards on. This is used for the
47
+ * social-login round-trip (`startOAuth`) — headless email/password uses `saveSession` directly.
48
+ */
49
+ declare function handleCallback(options?: {
50
+ returnPathname?: string;
51
+ }): (request: next_server.NextRequest) => Promise<Response>;
52
+
53
+ /**
54
+ * Headless auth flows — the Spawn API over WorkOS's User Management. Our own screens call these;
55
+ * WorkOS does the crypto. On success the session cookie is sealed via `saveSession`, so the caller
56
+ * just redirects. Password/verify/reset flows are here; social login uses `startOAuth` + the
57
+ * `/callback` handler. WorkOS still sends the transactional emails (for now).
58
+ */
59
+ type FlowResult = {
60
+ status: "ok";
61
+ } | {
62
+ status: "verify";
63
+ email: string;
64
+ pendingToken?: string;
65
+ } | {
66
+ status: "error";
67
+ error: string;
68
+ };
69
+ declare const SUPPORTED_OAUTH: {
70
+ readonly google: "GoogleOAuth";
71
+ readonly microsoft: "MicrosoftOAuth";
72
+ readonly apple: "AppleOAuth";
73
+ readonly github: "GitHubOAuth";
74
+ };
75
+ type OAuthProvider = keyof typeof SUPPORTED_OAUTH;
76
+ /** Email + password sign-in. Seals the Spawn session on success. */
77
+ declare function signInWithPassword(input: {
78
+ email: string;
79
+ password: string;
80
+ }): Promise<FlowResult>;
81
+ /**
82
+ * Create an account. If the environment requires email verification, WorkOS sends the mail and we
83
+ * return `verify`; otherwise the new user is signed in and the session sealed.
84
+ */
85
+ declare function signUp(input: {
86
+ email: string;
87
+ password: string;
88
+ firstName?: string;
89
+ lastName?: string;
90
+ }): Promise<FlowResult>;
91
+ /**
92
+ * Verify an email with the code we mailed; seals the session on success. `pendingToken` comes from
93
+ * the preceding signUp/signIn result and is REQUIRED — the code alone cannot be redeemed.
94
+ */
95
+ declare function verifyEmail(input: {
96
+ code: string;
97
+ pendingToken?: string;
98
+ }): Promise<FlowResult>;
99
+ /** Request a password-reset email (WorkOS sends it). Always reports success (no account enumeration). */
100
+ declare function requestPasswordReset(input: {
101
+ email: string;
102
+ }): Promise<FlowResult>;
103
+ /** Complete a password reset with the token from the email + a new password. */
104
+ declare function resetPassword(input: {
105
+ token: string;
106
+ newPassword: string;
107
+ }): Promise<FlowResult>;
108
+ /** The WorkOS authorization URL to start a social-login round-trip; redirect the browser to it. */
109
+ declare function startOAuth(input: {
110
+ provider: OAuthProvider;
111
+ redirectUri?: string;
112
+ }): Promise<string>;
113
+
114
+ export { type FlowResult, type OAuthProvider, authProxy, currentUser, handleCallback, rejectedEmail, requestPasswordReset, requireUser, resetPassword, session, signInWithPassword, signOut, signUp, startOAuth, switchOrganization, userDisplayName, verifyEmail };
@@ -0,0 +1,238 @@
1
+ import { isWorkosConfigured, isAllowedEmail, publicPaths, workosClientId } from '../chunk-SISIAS4C.js';
2
+ import { redirect } from 'next/navigation';
3
+ import { authkitMiddleware, handleAuth, getWorkOS, saveSession } from '@workos-inc/authkit-nextjs';
4
+ import { headers, cookies } from 'next/headers';
5
+
6
+ async function currentUser() {
7
+ if (!isWorkosConfigured()) return null;
8
+ const { withAuth } = await import('@workos-inc/authkit-nextjs');
9
+ const { user } = await withAuth();
10
+ if (!user?.email || !isAllowedEmail(user.email)) return null;
11
+ return toUser(user);
12
+ }
13
+ async function requireUser(redirectTo = "/login") {
14
+ const user = await currentUser();
15
+ if (!user) redirect(redirectTo);
16
+ return user;
17
+ }
18
+ async function session() {
19
+ if (!isWorkosConfigured()) return null;
20
+ const { withAuth } = await import('@workos-inc/authkit-nextjs');
21
+ const info = await withAuth();
22
+ if (!info.user?.email || !isAllowedEmail(info.user.email)) return null;
23
+ return {
24
+ user: toUser(info.user),
25
+ organizationId: info.organizationId,
26
+ role: normalizeRole(info.role)
27
+ };
28
+ }
29
+ async function rejectedEmail() {
30
+ if (!isWorkosConfigured()) return null;
31
+ const { withAuth } = await import('@workos-inc/authkit-nextjs');
32
+ const { user } = await withAuth();
33
+ if (user?.email && !isAllowedEmail(user.email)) return user.email;
34
+ return null;
35
+ }
36
+ async function signOut() {
37
+ const { signOut: workosSignOut } = await import('@workos-inc/authkit-nextjs');
38
+ await workosSignOut();
39
+ }
40
+ async function switchOrganization(organizationId) {
41
+ const { switchToOrganization } = await import('@workos-inc/authkit-nextjs');
42
+ await switchToOrganization(organizationId);
43
+ }
44
+ function userDisplayName(user) {
45
+ return user.name?.trim() || user.email;
46
+ }
47
+ function toUser(u) {
48
+ const name = [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
49
+ return { id: u.id, email: u.email, name: name || u.email };
50
+ }
51
+ function normalizeRole(role) {
52
+ return role === "owner" || role === "admin" || role === "member" ? role : void 0;
53
+ }
54
+ function authProxy(options) {
55
+ if (options?.enforce) {
56
+ return authkitMiddleware({
57
+ middlewareAuth: {
58
+ enabled: true,
59
+ unauthenticatedPaths: options.unauthenticatedPaths ?? publicPaths()
60
+ }
61
+ });
62
+ }
63
+ return authkitMiddleware();
64
+ }
65
+ function handleCallback(options) {
66
+ return handleAuth({ returnPathname: options?.returnPathname ?? "/" });
67
+ }
68
+ var SUPPORTED_OAUTH = {
69
+ google: "GoogleOAuth",
70
+ microsoft: "MicrosoftOAuth",
71
+ apple: "AppleOAuth",
72
+ github: "GitHubOAuth"
73
+ };
74
+ async function origin() {
75
+ const h = await headers();
76
+ const proto = h.get("x-forwarded-proto") ?? "https";
77
+ const host = h.get("host") ?? "localhost:3000";
78
+ return `${proto}://${host}`;
79
+ }
80
+ var FRIENDLY = {
81
+ email_not_available: "An account with that email already exists. Try signing in instead.",
82
+ email_verification_required: "Check your email for a verification code to finish signing in.",
83
+ invalid_credentials: "Invalid email or password.",
84
+ invalid_one_time_code: "That code isn't right. Check the latest email and try again.",
85
+ password_strength_error: "That password is too weak. Use at least 8 characters, mixing letters and numbers.",
86
+ user_not_found: "Invalid email or password.",
87
+ organization_not_found: "That workspace no longer exists.",
88
+ mfa_enrollment: "This account needs multi-factor authentication set up before signing in.",
89
+ sso_required: "This account signs in through your company's identity provider.",
90
+ rate_limit_exceeded: "Too many attempts. Wait a minute and try again."
91
+ };
92
+ function errorCode(err) {
93
+ if (!err || typeof err !== "object") return void 0;
94
+ const code = err.code;
95
+ return typeof code === "string" ? code : void 0;
96
+ }
97
+ function message(err, fallback) {
98
+ const code = errorCode(err);
99
+ if (code && FRIENDLY[code]) return FRIENDLY[code];
100
+ const errors = err?.errors;
101
+ if (Array.isArray(errors)) {
102
+ const field = errors.find(
103
+ (e) => !!e && typeof e === "object" && typeof e.field === "string"
104
+ )?.field;
105
+ if (field === "password") return FRIENDLY.password_strength_error;
106
+ if (field === "email") return "That email doesn't look right.";
107
+ }
108
+ return fallback;
109
+ }
110
+ function pendingVerificationToken(err) {
111
+ if (!err || typeof err !== "object") return void 0;
112
+ const e = err;
113
+ if (e.code !== "email_verification_required") return void 0;
114
+ return typeof e.pendingAuthenticationToken === "string" ? e.pendingAuthenticationToken : void 0;
115
+ }
116
+ var PENDING_COOKIE = "spawn_pending_verification";
117
+ var PENDING_TTL_SECONDS = 15 * 60;
118
+ async function rememberPendingToken(token) {
119
+ (await cookies()).set(PENDING_COOKIE, token, {
120
+ httpOnly: true,
121
+ secure: process.env.NODE_ENV === "production",
122
+ sameSite: "lax",
123
+ path: "/",
124
+ maxAge: PENDING_TTL_SECONDS
125
+ });
126
+ }
127
+ async function takePendingToken() {
128
+ return (await cookies()).get(PENDING_COOKIE)?.value;
129
+ }
130
+ async function clearPendingToken() {
131
+ (await cookies()).delete(PENDING_COOKIE);
132
+ }
133
+ async function signInWithPassword(input) {
134
+ try {
135
+ const res = await getWorkOS().userManagement.authenticateWithPassword({
136
+ clientId: workosClientId(),
137
+ email: input.email,
138
+ password: input.password
139
+ });
140
+ await saveSession(res, await origin());
141
+ return { status: "ok" };
142
+ } catch (err) {
143
+ const pendingToken = pendingVerificationToken(err);
144
+ if (pendingToken) {
145
+ await rememberPendingToken(pendingToken);
146
+ return { status: "verify", email: input.email, pendingToken };
147
+ }
148
+ return { status: "error", error: message(err, "Invalid email or password.") };
149
+ }
150
+ }
151
+ async function signUp(input) {
152
+ const workos = getWorkOS();
153
+ try {
154
+ const user = await workos.userManagement.createUser({
155
+ email: input.email,
156
+ password: input.password,
157
+ firstName: input.firstName,
158
+ lastName: input.lastName
159
+ });
160
+ try {
161
+ const res = await workos.userManagement.authenticateWithPassword({
162
+ clientId: workosClientId(),
163
+ email: input.email,
164
+ password: input.password
165
+ });
166
+ await saveSession(res, await origin());
167
+ return { status: "ok" };
168
+ } catch (err) {
169
+ const pendingToken = pendingVerificationToken(err);
170
+ if (pendingToken) {
171
+ await rememberPendingToken(pendingToken);
172
+ } else {
173
+ await workos.userManagement.sendVerificationEmail({ userId: user.id }).catch(() => {
174
+ });
175
+ }
176
+ return { status: "verify", email: input.email, pendingToken };
177
+ }
178
+ } catch (err) {
179
+ return { status: "error", error: message(err, "Could not create your account.") };
180
+ }
181
+ }
182
+ async function verifyEmail(input) {
183
+ const pendingToken = input.pendingToken ?? await takePendingToken();
184
+ if (!pendingToken) {
185
+ return {
186
+ status: "error",
187
+ error: "This verification session expired. Sign in again to get a new code."
188
+ };
189
+ }
190
+ try {
191
+ const res = await getWorkOS().userManagement.authenticateWithEmailVerification({
192
+ clientId: workosClientId(),
193
+ code: input.code,
194
+ pendingAuthenticationToken: pendingToken
195
+ });
196
+ await saveSession(res, await origin());
197
+ await clearPendingToken();
198
+ return { status: "ok" };
199
+ } catch (err) {
200
+ const code = errorCode(err);
201
+ if (code && code.startsWith("pending_authentication_token")) {
202
+ await clearPendingToken();
203
+ return {
204
+ status: "error",
205
+ error: "This verification session expired. Sign in again to get a new code."
206
+ };
207
+ }
208
+ return { status: "error", error: message(err, "That code didn't work. Try again.") };
209
+ }
210
+ }
211
+ async function requestPasswordReset(input) {
212
+ try {
213
+ await getWorkOS().userManagement.createPasswordReset({ email: input.email });
214
+ } catch {
215
+ }
216
+ return { status: "ok" };
217
+ }
218
+ async function resetPassword(input) {
219
+ try {
220
+ await getWorkOS().userManagement.resetPassword({
221
+ token: input.token,
222
+ newPassword: input.newPassword
223
+ });
224
+ return { status: "ok" };
225
+ } catch (err) {
226
+ return { status: "error", error: message(err, "That reset link is invalid or expired.") };
227
+ }
228
+ }
229
+ async function startOAuth(input) {
230
+ const redirectUri = input.redirectUri ?? `${await origin()}/callback`;
231
+ return getWorkOS().userManagement.getAuthorizationUrl({
232
+ clientId: workosClientId(),
233
+ provider: SUPPORTED_OAUTH[input.provider],
234
+ redirectUri
235
+ });
236
+ }
237
+
238
+ export { authProxy, currentUser, handleCallback, rejectedEmail, requestPasswordReset, requireUser, resetPassword, session, signInWithPassword, signOut, signUp, startOAuth, switchOrganization, userDisplayName, verifyEmail };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@spawn-llc/auth",
3
+ "version": "0.1.0",
4
+ "description": "Spawn shared identity — headless auth for the whole Spawn suite. One WorkOS project (named \"Spawn\") backs every app; this is the only place that touches it. WorkOS is an invisible engine.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./config": {
17
+ "types": "./dist/config.d.ts",
18
+ "import": "./dist/config.js"
19
+ },
20
+ "./nextjs": {
21
+ "types": "./dist/nextjs/index.d.ts",
22
+ "import": "./dist/nextjs/index.js"
23
+ },
24
+ "./*": {
25
+ "types": "./dist/*.d.ts",
26
+ "import": "./dist/*.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "IDENTITY.md"
33
+ ],
34
+ "scripts": {
35
+ "prepare": "git config core.hooksPath .githooks || true",
36
+ "build": "rm -rf dist && tsup",
37
+ "typecheck": "tsc --noEmit",
38
+ "prepublishOnly": "pnpm run typecheck && pnpm run build",
39
+ "release": "pnpm run release:patch",
40
+ "release:patch": "pnpm version patch -m \"v%s\" && git push --follow-tags",
41
+ "release:minor": "pnpm version minor -m \"v%s\" && git push --follow-tags",
42
+ "release:major": "pnpm version major -m \"v%s\" && git push --follow-tags"
43
+ },
44
+ "dependencies": {
45
+ "@workos-inc/authkit-nextjs": "^4.2.0",
46
+ "@workos-inc/node": "^10.8.0"
47
+ },
48
+ "peerDependencies": {
49
+ "next": ">=16",
50
+ "react": "^18.2.0 || ^19.0.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.20.1",
57
+ "@types/react": "^19.0.0",
58
+ "next": "^16.2.10",
59
+ "tsup": "^8.5.1",
60
+ "typescript": "^5.7.0"
61
+ }
62
+ }