@storyshelf/auth 0.6.1 → 0.6.2

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.
@@ -1,7 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { ensureEngineIdentity, tableColumn } from "./invites.mjs";
4
+ import { MIN_PASSWORD_LENGTH, ensureEngineIdentity, tableColumn } from "./invites.mjs";
5
5
  import { eq } from "@storyshelf/core/orm";
6
6
  import { ulid } from "@storyshelf/core/utils";
7
7
  //#region src/bootstrap.ts
@@ -32,7 +32,7 @@ async function stagePasswordAdmin(db, email, name, now) {
32
32
  async function ensurePasswordAdmin(db, input) {
33
33
  const email = input.email.trim().toLowerCase();
34
34
  if (!ENGINE_EMAIL.test(email)) throw new Error(`AUTH_EMAIL "${email}" is not a valid email address (domain needs a dot)`);
35
- if (input.password.length < 12) throw new Error("Password must be at least 12 characters");
35
+ if (input.password.length < MIN_PASSWORD_LENGTH) throw new Error("Password must be at least 12 characters");
36
36
  const now = (/* @__PURE__ */ new Date()).toISOString();
37
37
  const shelfUser = await stagePasswordAdmin(db, email, input.name ?? "Admin", now);
38
38
  await ensureEngineIdentity(db, shelfUser, input.password);
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap.mjs","names":[],"sources":["../src/bootstrap.ts"],"sourcesContent":["/**\n * Development bootstrap: provision a local admin from environment credentials.\n *\n * Replaces the dropped shared-password tier for `dev-server`/`fly-app`:\n * with `AUTH_PASSWORD` set, the first boot creates an admin identity and\n * every boot aligns its engine credential with the env value (same\n * always-from-env semantics the shared tier had). Production deployments\n * use the invite flow instead.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport { eq } from \"@storyshelf/core/orm\";\nimport { ulid } from \"@storyshelf/core/utils\";\nimport { ensureEngineIdentity, MIN_PASSWORD_LENGTH, tableColumn } from \"./invites.ts\";\n\n/** Matches what Better Auth accepts at sign-in: the domain needs a dot. */\nconst ENGINE_EMAIL = /^[^\\s@]+@[^\\s@.]+(?:\\.[^\\s@.]+)+$/u;\n\n/** Input for {@link ensurePasswordAdmin}. */\nexport interface PasswordAdminInput {\n email: string;\n name?: string;\n password: string;\n}\n\n/** Find the env admin row, or stage it (admin role, no credential yet). */\nasync function stagePasswordAdmin(\n db: DatabaseAdapter,\n email: string,\n name: string,\n now: string,\n): Promise<Record<string, unknown>> {\n const rows: Record<string, unknown>[] = await db.list(db.tables.users, {\n where: eq(tableColumn(db.tables.users, \"email\"), email),\n limit: 1,\n });\n const existing = rows[0];\n if (existing) {\n return existing;\n }\n return await db.insert(db.tables.users, {\n id: ulid(),\n email,\n name,\n avatarUrl: null,\n role: \"admin\",\n lastLoginAt: null,\n createdAt: now,\n passwordHash: null,\n displayNameOverride: null,\n authProvider: \"local\",\n disabled: false,\n });\n}\n\n/** Provision (or refresh the credential of) the env-driven local admin. */\nexport async function ensurePasswordAdmin(\n db: DatabaseAdapter,\n input: PasswordAdminInput,\n): Promise<void> {\n const email = input.email.trim().toLowerCase();\n if (!ENGINE_EMAIL.test(email)) {\n // The engine rejects dotless domains (e.g. admin@local) at sign-in, so the account could never log in.\n throw new Error(`AUTH_EMAIL \"${email}\" is not a valid email address (domain needs a dot)`);\n }\n if (input.password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\"Password must be at least 12 characters\");\n }\n const now = new Date().toISOString();\n const shelfUser = await stagePasswordAdmin(db, email, input.name ?? \"Admin\", now);\n await ensureEngineIdentity(db, shelfUser, input.password);\n}\n"],"mappings":";;;;;;;;AAeA,MAAM,eAAe;;AAUrB,eAAe,mBACb,IACA,OACA,MACA,KACkC;CAKlC,MAAM,YAAW,MAJ6B,GAAG,KAAK,GAAG,OAAO,OAAO;EACrE,OAAO,GAAG,YAAY,GAAG,OAAO,OAAO,OAAO,GAAG,KAAK;EACtD,OAAO;CACT,CAAC,EAAA,CACqB;CACtB,IAAI,UACF,OAAO;CAET,OAAO,MAAM,GAAG,OAAO,GAAG,OAAO,OAAO;EACtC,IAAI,KAAK;EACT;EACA;EACA,WAAW;EACX,MAAM;EACN,aAAa;EACb,WAAW;EACX,cAAc;EACd,qBAAqB;EACrB,cAAc;EACd,UAAU;CACZ,CAAC;AACH;;AAGA,eAAsB,oBACpB,IACA,OACe;CACf,MAAM,QAAQ,MAAM,MAAM,KAAK,CAAC,CAAC,YAAY;CAC7C,IAAI,CAAC,aAAa,KAAK,KAAK,GAE1B,MAAM,IAAI,MAAM,eAAe,MAAM,oDAAoD;CAE3F,IAAI,MAAM,SAAS,SAAA,IACjB,MAAM,IAAI,MAAM,yCAAyC;CAE3D,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;CACnC,MAAM,YAAY,MAAM,mBAAmB,IAAI,OAAO,MAAM,QAAQ,SAAS,GAAG;CAChF,MAAM,qBAAqB,IAAI,WAAW,MAAM,QAAQ;AAC1D"}
1
+ {"version":3,"file":"bootstrap.mjs","names":[],"sources":["../src/bootstrap.ts"],"sourcesContent":["/**\n * Development bootstrap: provision a local admin from environment credentials.\n *\n * Replaces the dropped shared-password tier for `dev-server`/`fly-app`:\n * with `AUTH_PASSWORD` set, the first boot creates an admin identity and\n * every boot aligns its engine credential with the env value (same\n * always-from-env semantics the shared tier had). Production deployments\n * use the invite flow instead.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport { eq } from \"@storyshelf/core/orm\";\nimport { ulid } from \"@storyshelf/core/utils\";\nimport { ensureEngineIdentity, MIN_PASSWORD_LENGTH, tableColumn } from \"./invites.ts\";\n\n/** Matches what Better Auth accepts at sign-in: the domain needs a dot. */\nconst ENGINE_EMAIL = /^[^\\s@]+@[^\\s@.]+(?:\\.[^\\s@.]+)+$/u;\n\n/** Input for {@link ensurePasswordAdmin}. */\nexport interface PasswordAdminInput {\n email: string;\n name?: string;\n password: string;\n}\n\n/** Find the env admin row, or stage it (admin role, no credential yet). */\nasync function stagePasswordAdmin(\n db: DatabaseAdapter,\n email: string,\n name: string,\n now: string,\n): Promise<Record<string, unknown>> {\n const rows: Record<string, unknown>[] = await db.list(db.tables.users, {\n where: eq(tableColumn(db.tables.users, \"email\"), email),\n limit: 1,\n });\n const existing = rows[0];\n if (existing) {\n return existing;\n }\n return await db.insert(db.tables.users, {\n id: ulid(),\n email,\n name,\n avatarUrl: null,\n role: \"admin\",\n lastLoginAt: null,\n createdAt: now,\n passwordHash: null,\n displayNameOverride: null,\n authProvider: \"local\",\n disabled: false,\n });\n}\n\n/** Provision (or refresh the credential of) the env-driven local admin. */\nexport async function ensurePasswordAdmin(\n db: DatabaseAdapter,\n input: PasswordAdminInput,\n): Promise<void> {\n const email = input.email.trim().toLowerCase();\n if (!ENGINE_EMAIL.test(email)) {\n // The engine rejects dotless domains (e.g. admin@local) at sign-in, so the account could never log in.\n throw new Error(`AUTH_EMAIL \"${email}\" is not a valid email address (domain needs a dot)`);\n }\n if (input.password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\"Password must be at least 12 characters\");\n }\n const now = new Date().toISOString();\n const shelfUser = await stagePasswordAdmin(db, email, input.name ?? \"Admin\", now);\n await ensureEngineIdentity(db, shelfUser, input.password);\n}\n"],"mappings":";;;;;;;;AAeA,MAAM,eAAe;;AAUrB,eAAe,mBACb,IACA,OACA,MACA,KACkC;CAKlC,MAAM,YAAW,MAJ6B,GAAG,KAAK,GAAG,OAAO,OAAO;EACrE,OAAO,GAAG,YAAY,GAAG,OAAO,OAAO,OAAO,GAAG,KAAK;EACtD,OAAO;CACT,CAAC,EAAA,CACqB;CACtB,IAAI,UACF,OAAO;CAET,OAAO,MAAM,GAAG,OAAO,GAAG,OAAO,OAAO;EACtC,IAAI,KAAK;EACT;EACA;EACA,WAAW;EACX,MAAM;EACN,aAAa;EACb,WAAW;EACX,cAAc;EACd,qBAAqB;EACrB,cAAc;EACd,UAAU;CACZ,CAAC;AACH;;AAGA,eAAsB,oBACpB,IACA,OACe;CACf,MAAM,QAAQ,MAAM,MAAM,KAAK,CAAC,CAAC,YAAY;CAC7C,IAAI,CAAC,aAAa,KAAK,KAAK,GAE1B,MAAM,IAAI,MAAM,eAAe,MAAM,oDAAoD;CAE3F,IAAI,MAAM,SAAS,SAAS,qBAC1B,MAAM,IAAI,MAAM,yCAAyC;CAE3D,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;CACnC,MAAM,YAAY,MAAM,mBAAmB,IAAI,OAAO,MAAM,QAAQ,SAAS,GAAG;CAChF,MAAM,qBAAqB,IAAI,WAAW,MAAM,QAAQ;AAC1D"}
package/dist/engine.d.mts CHANGED
@@ -1,9 +1,9 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
+ import { Auth } from "@storyshelf/core/auth";
3
4
  import { BetterAuthPlugin } from "better-auth";
4
5
  import { DatabaseAdapter } from "@storyshelf/core/adapter/database";
5
6
  import { EmailSender } from "@storyshelf/core/adapter/email-sender";
6
- import { Auth } from "@storyshelf/core/auth";
7
7
  import { Logger } from "@storyshelf/core/logger";
8
8
  //#region src/engine.d.ts
9
9
  /** Provider id minted on every engine identity (method attribution is future work). */
@@ -1,11 +1,10 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
3
  import { Table } from "@storyshelf/core/orm";
4
+ import { MIN_PASSWORD_LENGTH } from "@storyshelf/core/auth";
4
5
  import { AuthUser } from "@storyshelf/core/types";
5
6
  import { DatabaseAdapter } from "@storyshelf/core/adapter/database";
6
7
  //#region src/invites.d.ts
7
- /** Minimum password length for local accounts (matches legacy policy). */
8
- declare const MIN_PASSWORD_LENGTH = 12;
9
8
  /** Input for {@link issueInvite}. */
10
9
  interface IssueInviteInput {
11
10
  email: string;
package/dist/invites.mjs CHANGED
@@ -4,12 +4,23 @@ import.meta.url;
4
4
  import { baseAuthTables } from "./auth-tables.mjs";
5
5
  import { eq, getTableColumns, sql } from "@storyshelf/core/orm";
6
6
  import { randomToken, sha256, timingSafeEqualString, ulid } from "@storyshelf/core/utils";
7
+ import { MIN_PASSWORD_LENGTH } from "@storyshelf/core/auth";
7
8
  import { hashPassword } from "better-auth/crypto";
8
9
  //#region src/invites.ts
10
+ /**
11
+ * Invite-only local accounts on the engine: our tables own the invite,
12
+ * Better Auth owns the credential.
13
+ *
14
+ * `issueInvite` stages a shelf `users` row plus a single-use token;
15
+ * `acceptInvite` verifies the token, then writes the engine `user` row and
16
+ * `credential` account row directly through the DatabaseAdapter. Direct
17
+ * writes (instead of the admin `createUser` endpoint) because invite accept
18
+ * is a pre-authentication flow and admin endpoints require a session by
19
+ * design. Passwords use Better Auth's own hasher so engine sign-in verifies
20
+ * them without format translation.
21
+ */
9
22
  const DEFAULT_INVITE_MS = 6048e5;
10
23
  const MAX_INVITE_MS = 31536e6;
11
- /** Minimum password length for local accounts (matches legacy policy). */
12
- const MIN_PASSWORD_LENGTH = 12;
13
24
  /**
14
25
  * Run `fn` inside a transaction. Invites are consume-once state: without
15
26
  * driver transactions concurrent accepts race, so transact is required
@@ -38,7 +49,7 @@ function tableColumn(table, name) {
38
49
  return column;
39
50
  }
40
51
  function assertPasswordPolicy(password) {
41
- if (password.length < 12) throw new Error("Password must be at least 12 characters");
52
+ if (password.length < MIN_PASSWORD_LENGTH) throw new Error("Password must be at least 12 characters");
42
53
  }
43
54
  async function listShelfUsersByEmail(db, email) {
44
55
  return await db.list(db.tables.users, {
@@ -1 +1 @@
1
- {"version":3,"file":"invites.mjs","names":[],"sources":["../src/invites.ts"],"sourcesContent":["import type { DatabaseAdapter, TxStore } from \"@storyshelf/core/adapter/database\";\nimport { eq, getTableColumns, sql } from \"@storyshelf/core/orm\";\nimport type { SQL, Table } from \"@storyshelf/core/orm\";\n/**\n * Invite-only local accounts on the engine: our tables own the invite,\n * Better Auth owns the credential.\n *\n * `issueInvite` stages a shelf `users` row plus a single-use token;\n * `acceptInvite` verifies the token, then writes the engine `user` row and\n * `credential` account row directly through the DatabaseAdapter. Direct\n * writes (instead of the admin `createUser` endpoint) because invite accept\n * is a pre-authentication flow and admin endpoints require a session by\n * design. Passwords use Better Auth's own hasher so engine sign-in verifies\n * them without format translation.\n */\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport { randomToken, sha256, timingSafeEqualString, ulid } from \"@storyshelf/core/utils\";\nimport { hashPassword } from \"better-auth/crypto\";\nimport { baseAuthTables } from \"./auth-tables.ts\";\n\nconst DEFAULT_INVITE_MS = 7 * 24 * 60 * 60 * 1000;\nconst MAX_INVITE_MS = 365 * 24 * 60 * 60 * 1000;\n\n/** Minimum password length for local accounts (matches legacy policy). */\nexport const MIN_PASSWORD_LENGTH = 12;\n\n/** Input for {@link issueInvite}. */\nexport interface IssueInviteInput {\n email: string;\n name: string;\n role: AuthUser[\"role\"];\n /** Invite lifetime override. Defaults to 7 days. */\n inviteExpiryMs?: number;\n}\n\n/** Input for {@link acceptInvite} and {@link verifyInvite}. */\nexport interface InviteTokenInput {\n inviteId: string;\n token: string;\n}\n\n/** Input for {@link acceptInvite}. */\nexport interface AcceptInviteInput extends InviteTokenInput {\n password: string;\n}\n\ntype ShelfRow = Record<string, unknown>;\n\n/**\n * CRUD surface invites run on: the top-level adapter or a transaction store\n * (TxStore carries no table handles, so callers attach them). Both\n * DatabaseAdapter and TxStore satisfy this structurally.\n */\nexport type InviteStore = Pick<TxStore, \"insert\" | \"update\" | \"get\" | \"remove\" | \"list\"> & {\n readonly tables: DatabaseAdapter[\"tables\"];\n};\n\n/**\n * Run `fn` inside a transaction. Invites are consume-once state: without\n * driver transactions concurrent accepts race, so transact is required\n * (every production driver offers it) rather than silently sequential.\n */\nasync function withInviteTx<R>(\n db: DatabaseAdapter,\n fn: (store: InviteStore) => Promise<R>,\n): Promise<R> {\n if (!db.transact) {\n throw new Error(\"Shelf invites require a database with transaction support\");\n }\n return await db.transact(async (tx) => await fn({ ...tx, tables: db.tables }));\n}\n\nfunction normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nfunction isEmail(value: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/u.test(value);\n}\n\nfunction inviteError(): Error {\n return new Error(\"Invalid or expired invite\");\n}\n\n/** Resolve a shelf/engine table column, failing fast on schema drift. */\nexport function tableColumn(table: Table, name: string): SQL {\n const column = getTableColumns(table)[name] as SQL | undefined;\n if (!column) {\n throw new Error(`Shelf table is missing column \"${name}\"`);\n }\n return column;\n}\n\nfunction assertPasswordPolicy(password: string): void {\n if (password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\"Password must be at least 12 characters\");\n }\n}\n\nasync function listShelfUsersByEmail(db: InviteStore, email: string): Promise<ShelfRow[]> {\n return await db.list(db.tables.users, {\n where: eq(tableColumn(db.tables.users, \"email\"), normalizeEmail(email)),\n limit: 1,\n });\n}\n\nasync function findEngineUserByEmail(db: InviteStore, email: string): Promise<ShelfRow | null> {\n // Case-insensitive: engine rows may carry provider-given casing while the\n // shelf normalizes (and UNIQUE(email) is case-sensitive, so an exact-only\n // lookup would 500 on a case-variant insert).\n const column = tableColumn(baseAuthTables.user, \"email\");\n const rows = (await db.list(baseAuthTables.user, {\n where: eq(sql`lower(${column})`, email.toLowerCase()),\n limit: 1,\n })) as ShelfRow[];\n return rows[0] ?? null;\n}\n\nasync function findCredentialAccount(db: InviteStore, userId: string): Promise<ShelfRow | null> {\n const rows = (await db.list(baseAuthTables.account, {\n where: eq(tableColumn(baseAuthTables.account, \"userId\"), userId),\n })) as ShelfRow[];\n return rows.find((row) => row[\"providerId\"] === \"credential\") ?? null;\n}\n\n/** Load the invite row, or throw the generic invite error. */\nasync function loadInvite(db: InviteStore, input: InviteTokenInput): Promise<ShelfRow> {\n const invite: ShelfRow | null = await db.get(db.tables.userInviteTokens, input.inviteId);\n if (!invite || invite[\"usedAt\"]) {\n throw inviteError();\n }\n // Fail closed: a corrupt expiresAt parses to NaN, and NaN comparisons\n // are false — without this check the invite would never expire.\n const expires = new Date(String(invite[\"expiresAt\"])).getTime();\n if (Number.isNaN(expires) || expires <= Date.now()) {\n throw inviteError();\n }\n if (!timingSafeEqualString(sha256(input.token), String(invite[\"tokenHash\"]))) {\n throw inviteError();\n }\n return invite;\n}\n\n/** Load the invite plus its shelf user, or throw the generic invite error. */\nasync function assertInviteValid(\n db: InviteStore,\n input: InviteTokenInput,\n): Promise<{ invite: ShelfRow; shelfUser: ShelfRow }> {\n const invite = await loadInvite(db, input);\n const shelfUser: ShelfRow | null = await db.get(db.tables.users, String(invite[\"userId\"]));\n if (!shelfUser || shelfUser[\"disabled\"]) {\n throw inviteError();\n }\n return { invite, shelfUser };\n}\n\n/** Create the credential account row, or refresh the existing password. */\nasync function upsertCredentialAccount(\n db: InviteStore,\n userId: string,\n password: string,\n now: string,\n): Promise<void> {\n const hash = await hashPassword(password);\n const account = await findCredentialAccount(db, userId);\n if (account) {\n await db.update(baseAuthTables.account, String(account[\"id\"]), {\n password: hash,\n updatedAt: now,\n });\n return;\n }\n await db.insert(baseAuthTables.account, {\n id: ulid(),\n userId,\n accountId: userId,\n providerId: \"credential\",\n password: hash,\n createdAt: now,\n updatedAt: now,\n });\n}\n\n/** Create the engine identity rows (shared id), or refresh the credential. */\nexport async function ensureEngineIdentity(\n db: InviteStore,\n shelfUser: ShelfRow,\n password: string,\n): Promise<void> {\n const userId = String(shelfUser[\"id\"]);\n const email = String(shelfUser[\"email\"]);\n const now = new Date().toISOString();\n // Verified-email linking only: an existing engine row takes the credential\n // when its email is verified (same owner). An unverified row (e.g. an OIDC\n // identity with an untrusted email claim) must stay separate — but engine\n // emails are UNIQUE, so no second row can mint: reject and point at SSO.\n const engineUser = await findEngineUserByEmail(db, email);\n if (engineUser && engineUser[\"emailVerified\"] !== true) {\n throw new Error(\n \"Email is registered with an unverified SSO identity; sign in with SSO instead\",\n );\n }\n if (!engineUser) {\n await db.insert(baseAuthTables.user, {\n id: userId,\n name: String(shelfUser[\"name\"]),\n email,\n emailVerified: true,\n image: null,\n createdAt: now,\n updatedAt: now,\n });\n }\n const targetId = engineUser ? String(engineUser[\"id\"]) : userId;\n await upsertCredentialAccount(db, targetId, password, now);\n}\n\nfunction toAuthUser(row: ShelfRow): AuthUser {\n return {\n id: String(row[\"id\"]),\n email: String(row[\"email\"]),\n name: String(row[\"name\"]),\n avatarUrl: (row[\"avatarUrl\"] as string | null) ?? undefined,\n role: row[\"role\"] as AuthUser[\"role\"],\n };\n}\n\nfunction inviteLifetimeMs(expiryMs: number | undefined): number {\n const lifetime = expiryMs ?? DEFAULT_INVITE_MS;\n if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > MAX_INVITE_MS) {\n throw new Error(\"Invite lifetime must be between 1ms and 365 days\");\n }\n return lifetime;\n}\n\n/** Refresh name/role on the existing row (re-invite is admin intent). */\nasync function refreshShelfUser(\n db: InviteStore,\n id: string,\n input: IssueInviteInput,\n): Promise<ShelfRow> {\n const updated: ShelfRow = await db.update(db.tables.users, id, {\n name: input.name.trim(),\n role: input.role,\n });\n return updated;\n}\n\nasync function stageShelfUser(db: InviteStore, input: IssueInviteInput): Promise<ShelfRow> {\n const email = normalizeEmail(input.email);\n if (!isEmail(email)) {\n throw new Error(\"Invalid email\");\n }\n if (input.name.trim() === \"\") {\n throw new Error(\"Name is required\");\n }\n const existing = await listShelfUsersByEmail(db, email);\n // Re-invite refreshes name and role: issuing is operator-only, so the\n // latest invite is the current admin intent (and the password flow).\n if (existing[0]) {\n return await refreshShelfUser(db, String(existing[0][\"id\"]), input);\n }\n const now = new Date().toISOString();\n return await db.insert(db.tables.users, {\n id: ulid(),\n email,\n name: input.name.trim(),\n avatarUrl: null,\n role: input.role,\n lastLoginAt: null,\n createdAt: now,\n passwordHash: null,\n displayNameOverride: null,\n authProvider: \"local\",\n disabled: false,\n });\n}\n\nasync function removeUnusedInvite(db: InviteStore, row: ShelfRow): Promise<void> {\n await db.remove(db.tables.userInviteTokens, String(row[\"id\"]));\n}\n\nasync function supersedeUnusedInvites(db: InviteStore, userId: string): Promise<void> {\n const existing = (await db.list(db.tables.userInviteTokens, {\n where: eq(tableColumn(db.tables.userInviteTokens, \"userId\"), userId),\n })) as ShelfRow[];\n await Promise.all(\n existing\n .filter((row) => !row[\"usedAt\"])\n .map(async (row) => {\n await removeUnusedInvite(db, row);\n }),\n );\n}\n\n/** Stage a shelf user and mint a single-use invite token (returned once). */\nexport async function issueInvite(\n db: DatabaseAdapter,\n input: IssueInviteInput,\n): Promise<{ inviteId: string; token: string; expiresAt: string }> {\n return await withInviteTx(db, async (store) => {\n const shelfUser = await stageShelfUser(store, input);\n const userId = String(shelfUser[\"id\"]);\n await supersedeUnusedInvites(store, userId);\n const now = new Date().toISOString();\n const expiresAt = new Date(Date.now() + inviteLifetimeMs(input.inviteExpiryMs)).toISOString();\n const inviteId = ulid();\n const token = randomToken(\"inv_\").value;\n await store.insert(store.tables.userInviteTokens, {\n id: inviteId,\n userId,\n tokenHash: sha256(token),\n expiresAt,\n usedAt: null,\n createdAt: now,\n });\n return { inviteId, token, expiresAt };\n });\n}\n\n/** Check an invite token without consuming it (drives the accept page). */\nexport async function verifyInvite(\n db: DatabaseAdapter,\n input: InviteTokenInput,\n): Promise<{ email: string; name: string }> {\n const { shelfUser } = await assertInviteValid(db, input);\n return { email: String(shelfUser[\"email\"]), name: String(shelfUser[\"name\"]) };\n}\n\n/** Whether an invite row is already spent (busy-downgrade probe). */\nasync function inviteSpent(db: InviteStore, inviteId: string): Promise<boolean> {\n const invite: ShelfRow | null = await db.get(db.tables.userInviteTokens, inviteId);\n return !!invite?.[\"usedAt\"];\n}\n\n/** Consume an invite: set the engine credential and return the shelf user. */\nexport async function acceptInvite(\n db: DatabaseAdapter,\n input: AcceptInviteInput,\n): Promise<AuthUser> {\n assertPasswordPolicy(input.password);\n try {\n return await withInviteTx(db, async (store) => {\n const { invite, shelfUser } = await assertInviteValid(store, input);\n await ensureEngineIdentity(store, shelfUser, input.password);\n const now = new Date().toISOString();\n await store.update(store.tables.users, String(shelfUser[\"id\"]), { lastLoginAt: now });\n await store.update(store.tables.userInviteTokens, String(invite[\"id\"]), { usedAt: now });\n return toAuthUser(shelfUser);\n });\n } catch (error) {\n // A transaction loser (e.g. sqlite's busy connection) surfaces a driver\n // error instead of the invite error; re-probe so callers see the stable\n // \"Invalid or expired invite\" whenever the winner consumed the token.\n if (await inviteSpent(db, input.inviteId)) {\n throw inviteError();\n }\n throw error;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"invites.mjs","names":[],"sources":["../src/invites.ts"],"sourcesContent":["import type { DatabaseAdapter, TxStore } from \"@storyshelf/core/adapter/database\";\n/**\n * Invite-only local accounts on the engine: our tables own the invite,\n * Better Auth owns the credential.\n *\n * `issueInvite` stages a shelf `users` row plus a single-use token;\n * `acceptInvite` verifies the token, then writes the engine `user` row and\n * `credential` account row directly through the DatabaseAdapter. Direct\n * writes (instead of the admin `createUser` endpoint) because invite accept\n * is a pre-authentication flow and admin endpoints require a session by\n * design. Passwords use Better Auth's own hasher so engine sign-in verifies\n * them without format translation.\n */\nimport { MIN_PASSWORD_LENGTH } from \"@storyshelf/core/auth\";\nimport { eq, getTableColumns, sql } from \"@storyshelf/core/orm\";\nimport type { SQL, Table } from \"@storyshelf/core/orm\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport { randomToken, sha256, timingSafeEqualString, ulid } from \"@storyshelf/core/utils\";\nimport { hashPassword } from \"better-auth/crypto\";\nimport { baseAuthTables } from \"./auth-tables.ts\";\n\nconst DEFAULT_INVITE_MS = 7 * 24 * 60 * 60 * 1000;\nconst MAX_INVITE_MS = 365 * 24 * 60 * 60 * 1000;\n\nexport { MIN_PASSWORD_LENGTH };\n\n/** Input for {@link issueInvite}. */\nexport interface IssueInviteInput {\n email: string;\n name: string;\n role: AuthUser[\"role\"];\n /** Invite lifetime override. Defaults to 7 days. */\n inviteExpiryMs?: number;\n}\n\n/** Input for {@link acceptInvite} and {@link verifyInvite}. */\nexport interface InviteTokenInput {\n inviteId: string;\n token: string;\n}\n\n/** Input for {@link acceptInvite}. */\nexport interface AcceptInviteInput extends InviteTokenInput {\n password: string;\n}\n\ntype ShelfRow = Record<string, unknown>;\n\n/**\n * CRUD surface invites run on: the top-level adapter or a transaction store\n * (TxStore carries no table handles, so callers attach them). Both\n * DatabaseAdapter and TxStore satisfy this structurally.\n */\nexport type InviteStore = Pick<TxStore, \"insert\" | \"update\" | \"get\" | \"remove\" | \"list\"> & {\n readonly tables: DatabaseAdapter[\"tables\"];\n};\n\n/**\n * Run `fn` inside a transaction. Invites are consume-once state: without\n * driver transactions concurrent accepts race, so transact is required\n * (every production driver offers it) rather than silently sequential.\n */\nasync function withInviteTx<R>(\n db: DatabaseAdapter,\n fn: (store: InviteStore) => Promise<R>,\n): Promise<R> {\n if (!db.transact) {\n throw new Error(\"Shelf invites require a database with transaction support\");\n }\n return await db.transact(async (tx) => await fn({ ...tx, tables: db.tables }));\n}\n\nfunction normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nfunction isEmail(value: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/u.test(value);\n}\n\nfunction inviteError(): Error {\n return new Error(\"Invalid or expired invite\");\n}\n\n/** Resolve a shelf/engine table column, failing fast on schema drift. */\nexport function tableColumn(table: Table, name: string): SQL {\n const column = getTableColumns(table)[name] as SQL | undefined;\n if (!column) {\n throw new Error(`Shelf table is missing column \"${name}\"`);\n }\n return column;\n}\n\nfunction assertPasswordPolicy(password: string): void {\n if (password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\"Password must be at least 12 characters\");\n }\n}\n\nasync function listShelfUsersByEmail(db: InviteStore, email: string): Promise<ShelfRow[]> {\n return await db.list(db.tables.users, {\n where: eq(tableColumn(db.tables.users, \"email\"), normalizeEmail(email)),\n limit: 1,\n });\n}\n\nasync function findEngineUserByEmail(db: InviteStore, email: string): Promise<ShelfRow | null> {\n // Case-insensitive: engine rows may carry provider-given casing while the\n // shelf normalizes (and UNIQUE(email) is case-sensitive, so an exact-only\n // lookup would 500 on a case-variant insert).\n const column = tableColumn(baseAuthTables.user, \"email\");\n const rows = (await db.list(baseAuthTables.user, {\n where: eq(sql`lower(${column})`, email.toLowerCase()),\n limit: 1,\n })) as ShelfRow[];\n return rows[0] ?? null;\n}\n\nasync function findCredentialAccount(db: InviteStore, userId: string): Promise<ShelfRow | null> {\n const rows = (await db.list(baseAuthTables.account, {\n where: eq(tableColumn(baseAuthTables.account, \"userId\"), userId),\n })) as ShelfRow[];\n return rows.find((row) => row[\"providerId\"] === \"credential\") ?? null;\n}\n\n/** Load the invite row, or throw the generic invite error. */\nasync function loadInvite(db: InviteStore, input: InviteTokenInput): Promise<ShelfRow> {\n const invite: ShelfRow | null = await db.get(db.tables.userInviteTokens, input.inviteId);\n if (!invite || invite[\"usedAt\"]) {\n throw inviteError();\n }\n // Fail closed: a corrupt expiresAt parses to NaN, and NaN comparisons\n // are false — without this check the invite would never expire.\n const expires = new Date(String(invite[\"expiresAt\"])).getTime();\n if (Number.isNaN(expires) || expires <= Date.now()) {\n throw inviteError();\n }\n if (!timingSafeEqualString(sha256(input.token), String(invite[\"tokenHash\"]))) {\n throw inviteError();\n }\n return invite;\n}\n\n/** Load the invite plus its shelf user, or throw the generic invite error. */\nasync function assertInviteValid(\n db: InviteStore,\n input: InviteTokenInput,\n): Promise<{ invite: ShelfRow; shelfUser: ShelfRow }> {\n const invite = await loadInvite(db, input);\n const shelfUser: ShelfRow | null = await db.get(db.tables.users, String(invite[\"userId\"]));\n if (!shelfUser || shelfUser[\"disabled\"]) {\n throw inviteError();\n }\n return { invite, shelfUser };\n}\n\n/** Create the credential account row, or refresh the existing password. */\nasync function upsertCredentialAccount(\n db: InviteStore,\n userId: string,\n password: string,\n now: string,\n): Promise<void> {\n const hash = await hashPassword(password);\n const account = await findCredentialAccount(db, userId);\n if (account) {\n await db.update(baseAuthTables.account, String(account[\"id\"]), {\n password: hash,\n updatedAt: now,\n });\n return;\n }\n await db.insert(baseAuthTables.account, {\n id: ulid(),\n userId,\n accountId: userId,\n providerId: \"credential\",\n password: hash,\n createdAt: now,\n updatedAt: now,\n });\n}\n\n/** Create the engine identity rows (shared id), or refresh the credential. */\nexport async function ensureEngineIdentity(\n db: InviteStore,\n shelfUser: ShelfRow,\n password: string,\n): Promise<void> {\n const userId = String(shelfUser[\"id\"]);\n const email = String(shelfUser[\"email\"]);\n const now = new Date().toISOString();\n // Verified-email linking only: an existing engine row takes the credential\n // when its email is verified (same owner). An unverified row (e.g. an OIDC\n // identity with an untrusted email claim) must stay separate — but engine\n // emails are UNIQUE, so no second row can mint: reject and point at SSO.\n const engineUser = await findEngineUserByEmail(db, email);\n if (engineUser && engineUser[\"emailVerified\"] !== true) {\n throw new Error(\n \"Email is registered with an unverified SSO identity; sign in with SSO instead\",\n );\n }\n if (!engineUser) {\n await db.insert(baseAuthTables.user, {\n id: userId,\n name: String(shelfUser[\"name\"]),\n email,\n emailVerified: true,\n image: null,\n createdAt: now,\n updatedAt: now,\n });\n }\n const targetId = engineUser ? String(engineUser[\"id\"]) : userId;\n await upsertCredentialAccount(db, targetId, password, now);\n}\n\nfunction toAuthUser(row: ShelfRow): AuthUser {\n return {\n id: String(row[\"id\"]),\n email: String(row[\"email\"]),\n name: String(row[\"name\"]),\n avatarUrl: (row[\"avatarUrl\"] as string | null) ?? undefined,\n role: row[\"role\"] as AuthUser[\"role\"],\n };\n}\n\nfunction inviteLifetimeMs(expiryMs: number | undefined): number {\n const lifetime = expiryMs ?? DEFAULT_INVITE_MS;\n if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > MAX_INVITE_MS) {\n throw new Error(\"Invite lifetime must be between 1ms and 365 days\");\n }\n return lifetime;\n}\n\n/** Refresh name/role on the existing row (re-invite is admin intent). */\nasync function refreshShelfUser(\n db: InviteStore,\n id: string,\n input: IssueInviteInput,\n): Promise<ShelfRow> {\n const updated: ShelfRow = await db.update(db.tables.users, id, {\n name: input.name.trim(),\n role: input.role,\n });\n return updated;\n}\n\nasync function stageShelfUser(db: InviteStore, input: IssueInviteInput): Promise<ShelfRow> {\n const email = normalizeEmail(input.email);\n if (!isEmail(email)) {\n throw new Error(\"Invalid email\");\n }\n if (input.name.trim() === \"\") {\n throw new Error(\"Name is required\");\n }\n const existing = await listShelfUsersByEmail(db, email);\n // Re-invite refreshes name and role: issuing is operator-only, so the\n // latest invite is the current admin intent (and the password flow).\n if (existing[0]) {\n return await refreshShelfUser(db, String(existing[0][\"id\"]), input);\n }\n const now = new Date().toISOString();\n return await db.insert(db.tables.users, {\n id: ulid(),\n email,\n name: input.name.trim(),\n avatarUrl: null,\n role: input.role,\n lastLoginAt: null,\n createdAt: now,\n passwordHash: null,\n displayNameOverride: null,\n authProvider: \"local\",\n disabled: false,\n });\n}\n\nasync function removeUnusedInvite(db: InviteStore, row: ShelfRow): Promise<void> {\n await db.remove(db.tables.userInviteTokens, String(row[\"id\"]));\n}\n\nasync function supersedeUnusedInvites(db: InviteStore, userId: string): Promise<void> {\n const existing = (await db.list(db.tables.userInviteTokens, {\n where: eq(tableColumn(db.tables.userInviteTokens, \"userId\"), userId),\n })) as ShelfRow[];\n await Promise.all(\n existing\n .filter((row) => !row[\"usedAt\"])\n .map(async (row) => {\n await removeUnusedInvite(db, row);\n }),\n );\n}\n\n/** Stage a shelf user and mint a single-use invite token (returned once). */\nexport async function issueInvite(\n db: DatabaseAdapter,\n input: IssueInviteInput,\n): Promise<{ inviteId: string; token: string; expiresAt: string }> {\n return await withInviteTx(db, async (store) => {\n const shelfUser = await stageShelfUser(store, input);\n const userId = String(shelfUser[\"id\"]);\n await supersedeUnusedInvites(store, userId);\n const now = new Date().toISOString();\n const expiresAt = new Date(Date.now() + inviteLifetimeMs(input.inviteExpiryMs)).toISOString();\n const inviteId = ulid();\n const token = randomToken(\"inv_\").value;\n await store.insert(store.tables.userInviteTokens, {\n id: inviteId,\n userId,\n tokenHash: sha256(token),\n expiresAt,\n usedAt: null,\n createdAt: now,\n });\n return { inviteId, token, expiresAt };\n });\n}\n\n/** Check an invite token without consuming it (drives the accept page). */\nexport async function verifyInvite(\n db: DatabaseAdapter,\n input: InviteTokenInput,\n): Promise<{ email: string; name: string }> {\n const { shelfUser } = await assertInviteValid(db, input);\n return { email: String(shelfUser[\"email\"]), name: String(shelfUser[\"name\"]) };\n}\n\n/** Whether an invite row is already spent (busy-downgrade probe). */\nasync function inviteSpent(db: InviteStore, inviteId: string): Promise<boolean> {\n const invite: ShelfRow | null = await db.get(db.tables.userInviteTokens, inviteId);\n return !!invite?.[\"usedAt\"];\n}\n\n/** Consume an invite: set the engine credential and return the shelf user. */\nexport async function acceptInvite(\n db: DatabaseAdapter,\n input: AcceptInviteInput,\n): Promise<AuthUser> {\n assertPasswordPolicy(input.password);\n try {\n return await withInviteTx(db, async (store) => {\n const { invite, shelfUser } = await assertInviteValid(store, input);\n await ensureEngineIdentity(store, shelfUser, input.password);\n const now = new Date().toISOString();\n await store.update(store.tables.users, String(shelfUser[\"id\"]), { lastLoginAt: now });\n await store.update(store.tables.userInviteTokens, String(invite[\"id\"]), { usedAt: now });\n return toAuthUser(shelfUser);\n });\n } catch (error) {\n // A transaction loser (e.g. sqlite's busy connection) surfaces a driver\n // error instead of the invite error; re-probe so callers see the stable\n // \"Invalid or expired invite\" whenever the winner consumed the token.\n if (await inviteSpent(db, input.inviteId)) {\n throw inviteError();\n }\n throw error;\n }\n}\n"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
+ import { unsignedToken } from "@storyshelf/core/auth";
3
4
  import { DatabaseAdapter } from "@storyshelf/core/adapter/database";
4
5
  //#region src/sessions.d.ts
5
6
  /** One device session for the profile page. */
@@ -11,11 +12,6 @@ interface EngineSessionInfo {
11
12
  createdAt: string;
12
13
  expiresAt: string;
13
14
  }
14
- /**
15
- * Raw session token from a possibly-signed cookie value (`token.signature`).
16
- * Single shared parser: cookies, CSRF binding, and table lookups must agree.
17
- */
18
- declare function unsignedToken(value: string): string;
19
15
  /** Active sessions for a user, newest first (expired rows are dropped). */
20
16
  declare function listUserSessions(db: DatabaseAdapter, userId: string): Promise<EngineSessionInfo[]>;
21
17
  //#endregion
package/dist/sessions.mjs CHANGED
@@ -4,14 +4,8 @@ import.meta.url;
4
4
  import { baseAuthTables } from "./auth-tables.mjs";
5
5
  import { tableColumn } from "./invites.mjs";
6
6
  import { eq } from "@storyshelf/core/orm";
7
+ import { unsignedToken } from "@storyshelf/core/auth";
7
8
  //#region src/sessions.ts
8
- /**
9
- * Raw session token from a possibly-signed cookie value (`token.signature`).
10
- * Single shared parser: cookies, CSRF binding, and table lookups must agree.
11
- */
12
- function unsignedToken(value) {
13
- return value.split(".")[0] ?? "";
14
- }
15
9
  /** Delete every session row for a user (eager revocation on disable). */
16
10
  async function deleteUserSessions(db, userId) {
17
11
  const rows = await db.list(baseAuthTables.session, { where: eq(tableColumn(baseAuthTables.session, "userId"), userId) });
@@ -1 +1 @@
1
- {"version":3,"file":"sessions.mjs","names":[],"sources":["../src/sessions.ts"],"sourcesContent":["/**\n * Shelf-side session inventory for the profile device list.\n *\n * Listing reads our `session` table directly (full rows, including tokens\n * for revoke + current-device matching). Mutations go through engine\n * endpoints (`/revoke-session` is ownership-checked server-side,\n * `/revoke-other-sessions` keeps the caller), never raw deletes.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport { eq } from \"@storyshelf/core/orm\";\nimport { baseAuthTables } from \"./auth-tables.ts\";\nimport { tableColumn } from \"./invites.ts\";\n\n/** One device session for the profile page. */\nexport interface EngineSessionInfo {\n id: string;\n token: string;\n ipAddress: string | null;\n userAgent: string | null;\n createdAt: string;\n expiresAt: string;\n}\n\n/**\n * Raw session token from a possibly-signed cookie value (`token.signature`).\n * Single shared parser: cookies, CSRF binding, and table lookups must agree.\n */\nexport function unsignedToken(value: string): string {\n return value.split(\".\")[0] ?? \"\";\n}\n\n/** Delete every session row for a user (eager revocation on disable). */\nexport async function deleteUserSessions(db: DatabaseAdapter, userId: string): Promise<number> {\n const rows = (await db.list(baseAuthTables.session, {\n where: eq(tableColumn(baseAuthTables.session, \"userId\"), userId),\n })) as Record<string, unknown>[];\n await Promise.all(\n rows.map(async (row) => {\n await db.remove(baseAuthTables.session, String(row[\"id\"]));\n }),\n );\n return rows.length;\n}\n\n/** Delete one session by token (accepts the signed cookie value or raw token). */\nexport async function deleteSessionByToken(db: DatabaseAdapter, token: string): Promise<boolean> {\n const raw = token.split(\".\")[0] ?? \"\";\n if (!raw) {\n return false;\n }\n const rows = (await db.list(baseAuthTables.session, {\n where: eq(tableColumn(baseAuthTables.session, \"token\"), raw),\n limit: 1,\n })) as Record<string, unknown>[];\n const row = rows[0];\n if (!row) {\n return false;\n }\n await db.remove(baseAuthTables.session, String(row[\"id\"]));\n return true;\n}\n\n/** Active sessions for a user, newest first (expired rows are dropped). */\nexport async function listUserSessions(\n db: DatabaseAdapter,\n userId: string,\n): Promise<EngineSessionInfo[]> {\n const rows = (await db.list(baseAuthTables.session, {\n where: eq(tableColumn(baseAuthTables.session, \"userId\"), userId),\n })) as Record<string, unknown>[];\n const now = Date.now();\n return rows\n .filter((row) => new Date(String(row[\"expiresAt\"])).getTime() > now)\n .map((row) => ({\n id: String(row[\"id\"]),\n token: String(row[\"token\"]),\n ipAddress: (row[\"ipAddress\"] as string | null) ?? null,\n userAgent: (row[\"userAgent\"] as string | null) ?? null,\n createdAt: String(row[\"createdAt\"]),\n expiresAt: String(row[\"expiresAt\"]),\n }))\n .toSorted((a, b) =>\n a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt < b.createdAt ? 1 : -1,\n );\n}\n"],"mappings":";;;;;;;;;;;AA2BA,SAAgB,cAAc,OAAuB;CACnD,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM;AAChC;;AAGA,eAAsB,mBAAmB,IAAqB,QAAiC;CAC7F,MAAM,OAAQ,MAAM,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC;CACD,MAAM,QAAQ,IACZ,KAAK,IAAI,OAAO,QAAQ;EACtB,MAAM,GAAG,OAAO,eAAe,SAAS,OAAO,IAAI,KAAK,CAAC;CAC3D,CAAC,CACH;CACA,OAAO,KAAK;AACd;;AAGA,eAAsB,qBAAqB,IAAqB,OAAiC;CAC/F,MAAM,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM;CACnC,IAAI,CAAC,KACH,OAAO;CAMT,MAAM,OAAM,MAJQ,GAAG,KAAK,eAAe,SAAS;EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,OAAO,GAAG,GAAG;EAC3D,OAAO;CACT,CAAC,EAAA,CACgB;CACjB,IAAI,CAAC,KACH,OAAO;CAET,MAAM,GAAG,OAAO,eAAe,SAAS,OAAO,IAAI,KAAK,CAAC;CACzD,OAAO;AACT;;AAGA,eAAsB,iBACpB,IACA,QAC8B;CAC9B,MAAM,OAAQ,MAAM,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC;CACD,MAAM,MAAM,KAAK,IAAI;CACrB,OAAO,KACJ,QAAQ,QAAQ,IAAI,KAAK,OAAO,IAAI,YAAY,CAAC,CAAC,CAAC,QAAQ,IAAI,GAAG,CAAC,CACnE,KAAK,SAAS;EACb,IAAI,OAAO,IAAI,KAAK;EACpB,OAAO,OAAO,IAAI,QAAQ;EAC1B,WAAY,IAAI,gBAAkC;EAClD,WAAY,IAAI,gBAAkC;EAClD,WAAW,OAAO,IAAI,YAAY;EAClC,WAAW,OAAO,IAAI,YAAY;CACpC,EAAE,CAAC,CACF,UAAU,GAAG,MACZ,EAAE,cAAc,EAAE,YAAY,EAAE,GAAG,cAAc,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,YAAY,IAAI,EAC3F;AACJ"}
1
+ {"version":3,"file":"sessions.mjs","names":[],"sources":["../src/sessions.ts"],"sourcesContent":["/**\n * Shelf-side session inventory for the profile device list.\n *\n * Listing reads our `session` table directly (full rows, including tokens\n * for revoke + current-device matching). Mutations go through engine\n * endpoints (`/revoke-session` is ownership-checked server-side,\n * `/revoke-other-sessions` keeps the caller), never raw deletes.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport { eq } from \"@storyshelf/core/orm\";\nimport { baseAuthTables } from \"./auth-tables.ts\";\nimport { tableColumn } from \"./invites.ts\";\n\n/** One device session for the profile page. */\nexport interface EngineSessionInfo {\n id: string;\n token: string;\n ipAddress: string | null;\n userAgent: string | null;\n createdAt: string;\n expiresAt: string;\n}\n\nexport { unsignedToken } from \"@storyshelf/core/auth\";\n\n/** Delete every session row for a user (eager revocation on disable). */\nexport async function deleteUserSessions(db: DatabaseAdapter, userId: string): Promise<number> {\n const rows = (await db.list(baseAuthTables.session, {\n where: eq(tableColumn(baseAuthTables.session, \"userId\"), userId),\n })) as Record<string, unknown>[];\n await Promise.all(\n rows.map(async (row) => {\n await db.remove(baseAuthTables.session, String(row[\"id\"]));\n }),\n );\n return rows.length;\n}\n\n/** Delete one session by token (accepts the signed cookie value or raw token). */\nexport async function deleteSessionByToken(db: DatabaseAdapter, token: string): Promise<boolean> {\n const raw = token.split(\".\")[0] ?? \"\";\n if (!raw) {\n return false;\n }\n const rows = (await db.list(baseAuthTables.session, {\n where: eq(tableColumn(baseAuthTables.session, \"token\"), raw),\n limit: 1,\n })) as Record<string, unknown>[];\n const row = rows[0];\n if (!row) {\n return false;\n }\n await db.remove(baseAuthTables.session, String(row[\"id\"]));\n return true;\n}\n\n/** Active sessions for a user, newest first (expired rows are dropped). */\nexport async function listUserSessions(\n db: DatabaseAdapter,\n userId: string,\n): Promise<EngineSessionInfo[]> {\n const rows = (await db.list(baseAuthTables.session, {\n where: eq(tableColumn(baseAuthTables.session, \"userId\"), userId),\n })) as Record<string, unknown>[];\n const now = Date.now();\n return rows\n .filter((row) => new Date(String(row[\"expiresAt\"])).getTime() > now)\n .map((row) => ({\n id: String(row[\"id\"]),\n token: String(row[\"token\"]),\n ipAddress: (row[\"ipAddress\"] as string | null) ?? null,\n userAgent: (row[\"userAgent\"] as string | null) ?? null,\n createdAt: String(row[\"createdAt\"]),\n expiresAt: String(row[\"expiresAt\"]),\n }))\n .toSorted((a, b) =>\n a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt < b.createdAt ? 1 : -1,\n );\n}\n"],"mappings":";;;;;;;;;AA0BA,eAAsB,mBAAmB,IAAqB,QAAiC;CAC7F,MAAM,OAAQ,MAAM,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC;CACD,MAAM,QAAQ,IACZ,KAAK,IAAI,OAAO,QAAQ;EACtB,MAAM,GAAG,OAAO,eAAe,SAAS,OAAO,IAAI,KAAK,CAAC;CAC3D,CAAC,CACH;CACA,OAAO,KAAK;AACd;;AAGA,eAAsB,qBAAqB,IAAqB,OAAiC;CAC/F,MAAM,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM;CACnC,IAAI,CAAC,KACH,OAAO;CAMT,MAAM,OAAM,MAJQ,GAAG,KAAK,eAAe,SAAS;EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,OAAO,GAAG,GAAG;EAC3D,OAAO;CACT,CAAC,EAAA,CACgB;CACjB,IAAI,CAAC,KACH,OAAO;CAET,MAAM,GAAG,OAAO,eAAe,SAAS,OAAO,IAAI,KAAK,CAAC;CACzD,OAAO;AACT;;AAGA,eAAsB,iBACpB,IACA,QAC8B;CAC9B,MAAM,OAAQ,MAAM,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC;CACD,MAAM,MAAM,KAAK,IAAI;CACrB,OAAO,KACJ,QAAQ,QAAQ,IAAI,KAAK,OAAO,IAAI,YAAY,CAAC,CAAC,CAAC,QAAQ,IAAI,GAAG,CAAC,CACnE,KAAK,SAAS;EACb,IAAI,OAAO,IAAI,KAAK;EACpB,OAAO,OAAO,IAAI,QAAQ;EAC1B,WAAY,IAAI,gBAAkC;EAClD,WAAY,IAAI,gBAAkC;EAClD,WAAW,OAAO,IAAI,YAAY;EAClC,WAAW,OAAO,IAAI,YAAY;CACpC,EAAE,CAAC,CACF,UAAU,GAAG,MACZ,EAAE,cAAc,EAAE,YAAY,EAAE,GAAG,cAAc,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,YAAY,IAAI,EAC3F;AACJ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storyshelf/auth",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Better Auth engine for StoryShelf: opaque DBAdapter bridge, shelf factory, and auth presets.",
5
5
  "keywords": [
6
6
  "auth",
@@ -26,6 +26,9 @@
26
26
  "dist"
27
27
  ],
28
28
  "type": "module",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
29
32
  "sideEffects": false,
30
33
  "types": "./dist/index.d.mts",
31
34
  "exports": {
@@ -53,13 +56,13 @@
53
56
  "dependencies": {
54
57
  "@better-auth/passkey": "1.7.6",
55
58
  "@better-auth/sso": "1.7.6",
56
- "@storyshelf/core": "0.6.1",
59
+ "@storyshelf/core": "0.6.2",
57
60
  "better-auth": "1.7.6",
58
61
  "zod": "^4.2.0"
59
62
  },
60
63
  "devDependencies": {
61
- "@storyshelf/db-postgres": "0.6.1",
62
- "@storyshelf/db-sqlite": "0.6.1",
64
+ "@storyshelf/db-postgres": "0.6.2",
65
+ "@storyshelf/db-sqlite": "0.6.2",
63
66
  "@types/node": "^26.0.0",
64
67
  "@vitest/coverage-v8": "^4.0.0",
65
68
  "oxfmt": "^0.65.0",