@stacksjs/auth 0.70.88 → 0.70.90

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.
Files changed (47) hide show
  1. package/dist/authentication.d.ts +43 -0
  2. package/dist/authentication.js +413 -0
  3. package/dist/authenticator.d.ts +17 -0
  4. package/dist/authenticator.js +14 -0
  5. package/dist/authorizable.d.ts +77 -0
  6. package/dist/authorizable.js +28 -0
  7. package/dist/client.d.ts +22 -0
  8. package/dist/client.js +26 -0
  9. package/dist/email-verification.d.ts +29 -0
  10. package/dist/email-verification.js +111 -0
  11. package/dist/gate.d.ts +180 -0
  12. package/dist/gate.js +203 -0
  13. package/dist/index.d.ts +36 -0
  14. package/dist/index.js +26 -0
  15. package/dist/internal-constants.d.ts +19 -0
  16. package/dist/internal-constants.js +1 -0
  17. package/dist/middleware.d.ts +14 -0
  18. package/dist/middleware.js +28 -0
  19. package/dist/passkey.d.ts +97 -0
  20. package/dist/passkey.js +76 -0
  21. package/dist/password/reset.d.ts +10 -0
  22. package/dist/password/reset.js +156 -0
  23. package/dist/policy.d.ts +33 -0
  24. package/dist/policy.js +91 -0
  25. package/dist/rate-limiter.d.ts +44 -0
  26. package/dist/rate-limiter.js +91 -0
  27. package/dist/rbac-seed.d.ts +24 -0
  28. package/dist/rbac-seed.js +30 -0
  29. package/dist/rbac-store-bqb.d.ts +18 -0
  30. package/dist/rbac-store-bqb.js +180 -0
  31. package/dist/rbac.d.ts +265 -0
  32. package/dist/rbac.js +324 -0
  33. package/dist/register.d.ts +3 -0
  34. package/dist/register.js +43 -0
  35. package/dist/session-auth.d.ts +67 -0
  36. package/dist/session-auth.js +151 -0
  37. package/dist/team.d.ts +121 -0
  38. package/dist/team.js +88 -0
  39. package/dist/token.d.ts +16 -0
  40. package/dist/token.js +21 -0
  41. package/dist/tokens.d.ts +284 -0
  42. package/dist/tokens.js +489 -0
  43. package/dist/two-factor.d.ts +67 -0
  44. package/dist/two-factor.js +113 -0
  45. package/dist/user.d.ts +34 -0
  46. package/dist/user.js +41 -0
  47. package/package.json +3 -3
@@ -0,0 +1,97 @@
1
+ import { User } from '@stacksjs/orm';
2
+ import type { Insertable } from '@stacksjs/database';
3
+ import type { VerifiedRegistrationResponse } from '@stacksjs/ts-auth';
4
+ // Re-export WebAuthn types from ts-auth
5
+ export type {
6
+ VerifiedRegistrationResponse,
7
+ VerifiedAuthenticationResponse,
8
+ RegistrationCredential,
9
+ AuthenticationCredential,
10
+ PublicKeyCredentialCreationOptions,
11
+ PublicKeyCredentialRequestOptions,
12
+ RegistrationOptions,
13
+ AuthenticationOptions,
14
+ } from '@stacksjs/ts-auth';
15
+ export declare function getUserPasskeys(userId: number): Promise<PasskeyAttribute[]>;
16
+ export declare function getUserPasskey(userId: number, passkeyId: string): Promise<PasskeyAttribute | undefined>;
17
+ /**
18
+ * Persist the post-verification authenticator counter and refresh the
19
+ * passkey's last-used timestamp. WebAuthn's anti-cloning guarantee
20
+ * depends on the relying party rejecting any authentication whose
21
+ * `newCounter` is **not strictly greater** than the stored value —
22
+ * authenticators monotonically increment their counter on every use,
23
+ * so a counter that doesn't advance (or goes backwards) signals a
24
+ * cloned or replayed credential.
25
+ *
26
+ * Returns `true` when the counter was updated successfully; `false`
27
+ * when the new counter is not greater than the stored one (the
28
+ * authentication MUST be rejected by the caller in that case).
29
+ * stacksjs/stacks#1861 A-4.
30
+ */
31
+ export declare function updatePasskeyCounter(userId: number, passkeyId: string, newCounter: number): Promise<boolean>;
32
+ export declare function setCurrentRegistrationOptions(user: UserModel, verified: VerifiedRegistrationResponse): Promise<void>;
33
+ /**
34
+ * Persist a server-issued WebAuthn challenge for the given user +
35
+ * purpose. Deletes any prior challenge for the same (user, purpose)
36
+ * pair so a fresh `generateOptions` invalidates the previous one.
37
+ *
38
+ * The unique index on `(user_id, purpose)` enforces single-outstanding
39
+ * at the DB layer; this delete makes the upsert safe even on installs
40
+ * that ran an earlier auth:setup before the unique index existed.
41
+ */
42
+ export declare function storeWebAuthnChallenge(userId: number, challenge: string | Uint8Array, purpose: WebAuthnChallengePurpose, ttlSeconds?: number): Promise<void>;
43
+ /**
44
+ * Read + delete (single-use) a WebAuthn challenge for the given user
45
+ * + purpose. Returns `null` when no outstanding challenge exists or
46
+ * when the stored challenge has expired.
47
+ *
48
+ * Single-use semantics matter: a successful verify must invalidate
49
+ * the challenge so a captured assertion can't be replayed even within
50
+ * the TTL window. Callers MUST treat a `null` return as a verification
51
+ * failure.
52
+ */
53
+ export declare function consumeWebAuthnChallenge(userId: number, purpose: WebAuthnChallengePurpose): Promise<Uint8Array | null>;
54
+ export declare interface PasskeyAttribute {
55
+ id: string
56
+ cred_public_key: string
57
+ user_id: number
58
+ webauthn_user_id: string
59
+ counter: number
60
+ credential_type: string
61
+ device_type: string
62
+ backup_eligible: boolean
63
+ backup_status: boolean
64
+ transports?: string
65
+ created_at?: Date
66
+ last_used_at: string
67
+ }
68
+ declare type UserModel = NonNullable<Awaited<ReturnType<typeof User.find>>>;
69
+ declare type PasskeyInsertable = Insertable<PasskeyAttribute>;
70
+ // =============================================================================
71
+ // WebAuthn challenge persistence (stacksjs/stacks#1866)
72
+ // =============================================================================
73
+ //
74
+ // WebAuthn relying parties MUST verify the assertion's challenge
75
+ // against a server-issued nonce. Previously Stacks returned the
76
+ // challenge in `generateOptions` and trusted the client to echo it
77
+ // back on verify — so any attacker who captured the assertion AND the
78
+ // challenge could replay the response. Persisting the challenge
79
+ // server-side and consuming it on verify closes that gap.
80
+ //
81
+ // The default TTL is 5 minutes — long enough for slow biometric
82
+ // flows, short enough to bound the replay window. Override per-call
83
+ // via the `ttlSeconds` parameter.
84
+ export type WebAuthnChallengePurpose = 'registration' | 'authentication';
85
+ // Re-export WebAuthn functions from ts-auth
86
+ export {
87
+ generateRegistrationOptions,
88
+ generateAuthenticationOptions,
89
+ verifyRegistrationResponse,
90
+ verifyAuthenticationResponse,
91
+ // Browser-side functions (for client use)
92
+ startRegistration,
93
+ startAuthentication,
94
+ browserSupportsWebAuthn,
95
+ browserSupportsWebAuthnAutofill,
96
+ platformAuthenticatorIsAvailable,
97
+ } from '@stacksjs/ts-auth';
@@ -0,0 +1,76 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { db } from "@stacksjs/database";
3
+ import { User } from "@stacksjs/orm";
4
+ export {
5
+ generateRegistrationOptions,
6
+ generateAuthenticationOptions,
7
+ verifyRegistrationResponse,
8
+ verifyAuthenticationResponse,
9
+ startRegistration,
10
+ startAuthentication,
11
+ browserSupportsWebAuthn,
12
+ browserSupportsWebAuthnAutofill,
13
+ platformAuthenticatorIsAvailable
14
+ } from "@stacksjs/ts-auth";
15
+ export async function getUserPasskeys(userId) {
16
+ return await db.selectFrom("passkeys").selectAll().where("user_id", "=", userId).execute();
17
+ }
18
+ export async function getUserPasskey(userId, passkeyId) {
19
+ return await db.selectFrom("passkeys").selectAll().where("id", "=", passkeyId).where("user_id", "=", userId).executeTakeFirst();
20
+ }
21
+ export async function updatePasskeyCounter(userId, passkeyId, newCounter) {
22
+ const passkey = await getUserPasskey(userId, passkeyId);
23
+ if (!passkey)
24
+ return !1;
25
+ const stored = Number(passkey.counter ?? 0);
26
+ if (newCounter <= stored && !(newCounter === 0 && stored === 0))
27
+ return !1;
28
+ await db.updateTable("passkeys").set({ counter: newCounter, last_used_at: formatDateTime() }).where("id", "=", passkeyId).where("user_id", "=", userId).execute();
29
+ return !0;
30
+ }
31
+ export async function setCurrentRegistrationOptions(user, verified) {
32
+ const credentialId = verified.registrationInfo?.credential.id, credentialPublicKey = verified.registrationInfo?.credential.publicKey;
33
+ if (!credentialId)
34
+ throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");
35
+ if (!credentialPublicKey)
36
+ throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");
37
+ const passkeyData = {
38
+ id: credentialId,
39
+ cred_public_key: JSON.stringify(credentialPublicKey),
40
+ user_id: user.id,
41
+ webauthn_user_id: user.email || "",
42
+ counter: verified.registrationInfo?.credential.counter || 0,
43
+ credential_type: verified.registrationInfo?.credentialType || "",
44
+ device_type: verified.registrationInfo?.credentialDeviceType || "",
45
+ backup_eligible: !1,
46
+ backup_status: verified.registrationInfo?.credentialBackedUp || !1,
47
+ transports: JSON.stringify(["internal"]),
48
+ last_used_at: formatDateTime()
49
+ };
50
+ await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow();
51
+ }
52
+ function formatDateTime() {
53
+ const date = new Date, pad = (num) => String(num).padStart(2, "0"), year = date.getFullYear(), month = pad(date.getMonth() + 1), day = pad(date.getDate()), hours = pad(date.getHours()), minutes = pad(date.getMinutes()), seconds = pad(date.getSeconds());
54
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
55
+ }
56
+ const DEFAULT_CHALLENGE_TTL_SECONDS = 300;
57
+ export async function storeWebAuthnChallenge(userId, challenge, purpose, ttlSeconds = DEFAULT_CHALLENGE_TTL_SECONDS) {
58
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(), encodedChallenge = typeof challenge === "string" ? challenge : Buffer.from(challenge).toString("base64url");
59
+ await db.deleteFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).execute();
60
+ await db.insertInto("webauthn_challenges").values({
61
+ user_id: userId,
62
+ challenge: encodedChallenge,
63
+ purpose,
64
+ expires_at: expiresAt
65
+ }).execute();
66
+ }
67
+ export async function consumeWebAuthnChallenge(userId, purpose) {
68
+ const row = await db.selectFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).selectAll().executeTakeFirst();
69
+ if (!row)
70
+ return null;
71
+ await db.deleteFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).execute();
72
+ const expiresAt = row.expires_at ? new Date(String(row.expires_at)).getTime() : 0;
73
+ if (Date.now() > expiresAt)
74
+ return null;
75
+ return Buffer.from(String(row.challenge), "base64url");
76
+ }
@@ -0,0 +1,10 @@
1
+ export declare function passwordResets(email: string): PasswordResetActions;
2
+ export declare interface PasswordResetResult {
3
+ success: boolean
4
+ message?: string
5
+ }
6
+ export declare interface PasswordResetActions {
7
+ sendEmail: () => Promise<void>
8
+ verifyToken: (token: string) => Promise<boolean>
9
+ resetPassword: (token: string, newPassword: string) => Promise<PasswordResetResult>
10
+ }
@@ -0,0 +1,156 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { config } from "@stacksjs/config";
3
+ import { db } from "@stacksjs/database";
4
+ import { mail, template } from "@stacksjs/email";
5
+ import { log } from "@stacksjs/logging";
6
+ import { formatDate } from "@stacksjs/orm";
7
+ import { makeHash, verifyHash } from "@stacksjs/security";
8
+ import { sessionDestroyAll } from "../session-auth";
9
+ import { revokeAllTokens } from "../tokens";
10
+ function getTokenExpireMinutes() {
11
+ return config.auth.passwordReset?.expire ?? 60;
12
+ }
13
+ function isWithinExpiry(row) {
14
+ const explicit = row.expires_at;
15
+ if (typeof explicit === "string" || explicit instanceof Date)
16
+ return new Date(explicit).getTime() > Date.now();
17
+ const created = row.created_at;
18
+ if (typeof created === "string" || created instanceof Date) {
19
+ const expireMinutes = getTokenExpireMinutes();
20
+ return new Date(created).getTime() + expireMinutes * 60000 > Date.now();
21
+ }
22
+ return !1;
23
+ }
24
+ async function sendPasswordChangedNotification(userEmail) {
25
+ const appName = config.app.name || "Stacks", supportEmail = config.app.supportEmail || config.email?.from?.address || "", changedAt = new Date().toLocaleString("en-US", {
26
+ dateStyle: "full",
27
+ timeStyle: "short"
28
+ });
29
+ try {
30
+ const { html, text } = await template("password-changed", {
31
+ subject: `Your ${appName} password has been changed`,
32
+ variables: {
33
+ changedAt,
34
+ supportEmail
35
+ }
36
+ });
37
+ if (!html && !text) {
38
+ await mail.send({
39
+ to: userEmail,
40
+ subject: `Your ${appName} password has been changed`,
41
+ text: `Your ${appName} password was changed on ${changedAt}.${supportEmail ? ` If this wasn't you, contact ${supportEmail}.` : ""}`
42
+ });
43
+ return;
44
+ }
45
+ await mail.send({
46
+ to: userEmail,
47
+ subject: `Your ${appName} password has been changed`,
48
+ text,
49
+ html
50
+ });
51
+ } catch (error) {
52
+ console.error("[PasswordReset] Failed to send password changed notification:", error);
53
+ }
54
+ }
55
+ export function passwordResets(email) {
56
+ function generateResetToken() {
57
+ return randomBytes(32).toString("hex");
58
+ }
59
+ async function createResetToken() {
60
+ const token = generateResetToken(), hashedToken = await makeHash(token, { algorithm: "bcrypt" }), expireMinutes = getTokenExpireMinutes(), expiresAt = new Date(Date.now() + expireMinutes * 60000).toISOString();
61
+ await db.deleteFrom("password_resets").where("email", "=", email).execute();
62
+ await db.insertInto("password_resets").values({
63
+ email,
64
+ token: hashedToken,
65
+ expires_at: expiresAt
66
+ }).executeTakeFirst();
67
+ return token;
68
+ }
69
+ async function sendEmail() {
70
+ if (!await db.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst())
71
+ return;
72
+ const token = await createResetToken(), expireMinutes = getTokenExpireMinutes(), appName = config.app.name || "Stacks", base = config.app.url ? `https://${config.app.url}` : `http://localhost:${process.env.PORT || "3000"}`, filled = (config.auth.passwordReset?.url ?? "/password/reset/{token}?email={email}").replace("{token}", token).replace("{email}", encodeURIComponent(email)), resetUrl = /^https?:\/\//.test(filled) ? filled : `${base}${filled.startsWith("/") ? "" : "/"}${filled}`;
73
+ try {
74
+ const { html, text } = await template("password-reset", {
75
+ subject: `Reset Your ${appName} Password`,
76
+ variables: {
77
+ resetUrl,
78
+ expireMinutes
79
+ }
80
+ });
81
+ if (!html && !text)
82
+ throw Error("password-reset template missing or rendered empty");
83
+ await mail.send({
84
+ to: email,
85
+ subject: `Reset Your ${appName} Password`,
86
+ text,
87
+ html
88
+ });
89
+ } catch (templateError) {
90
+ const msg = templateError instanceof Error ? templateError.message : String(templateError);
91
+ console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);
92
+ await mail.send({
93
+ to: email,
94
+ subject: `Reset Your ${appName} Password`,
95
+ text: `Reset your password by visiting: ${resetUrl}
96
+
97
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`
98
+ });
99
+ }
100
+ }
101
+ async function verifyToken(token) {
102
+ const result = await db.selectFrom("password_resets").where("email", "=", email).selectAll().executeTakeFirst();
103
+ if (!result)
104
+ return !1;
105
+ if (!isWithinExpiry(result)) {
106
+ await db.deleteFrom("password_resets").where("email", "=", email).execute();
107
+ return !1;
108
+ }
109
+ const hashedToken = result.token;
110
+ return await verifyHash(token, hashedToken);
111
+ }
112
+ async function resetPassword(token, newPassword) {
113
+ const result = await db.transaction(async (rawTrx) => {
114
+ const trx = rawTrx, resetRecord = await trx.selectFrom("password_resets").where("email", "=", email).selectAll().executeTakeFirst();
115
+ if (!resetRecord)
116
+ return { success: !1, message: "Invalid or expired reset token" };
117
+ if (!isWithinExpiry(resetRecord)) {
118
+ await trx.deleteFrom("password_resets").where("email", "=", email).execute();
119
+ return { success: !1, message: "This password reset link has expired. Please request a new one." };
120
+ }
121
+ const hashedToken = resetRecord.token;
122
+ if (!await verifyHash(token, hashedToken))
123
+ return { success: !1, message: "Invalid or expired reset token" };
124
+ const user = await trx.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst();
125
+ if (!user)
126
+ return { success: !1, message: "Invalid or expired reset token" };
127
+ const hashedPassword = await makeHash(newPassword, { algorithm: "bcrypt" });
128
+ try {
129
+ await trx.updateTable("users").set({ password: hashedPassword, password_changed_at: formatDate(new Date) }).where("email", "=", email).executeTakeFirst();
130
+ } catch (err) {
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ if (/password_changed_at|no such column|unknown column/i.test(message)) {
133
+ log.warn("[PasswordReset] password_changed_at column missing \u2014 run `buddy migrate`; resetting without the credential-version stamp");
134
+ await trx.updateTable("users").set({ password: hashedPassword }).where("email", "=", email).executeTakeFirst();
135
+ } else
136
+ throw err;
137
+ }
138
+ await trx.deleteFrom("password_resets").where("email", "=", email).execute();
139
+ return { success: !0, userId: Number(user.id) };
140
+ });
141
+ if (result.success) {
142
+ await revokeAllTokens(result.userId);
143
+ await sessionDestroyAll(result.userId);
144
+ sendPasswordChangedNotification(email).catch((err) => {
145
+ console.error("[PasswordReset] Failed to send notification:", err);
146
+ });
147
+ return { success: !0 };
148
+ }
149
+ return result;
150
+ }
151
+ return {
152
+ sendEmail,
153
+ verifyToken,
154
+ resetPassword
155
+ };
156
+ }
@@ -0,0 +1,33 @@
1
+ import { AuthorizationResponse } from './gate';
2
+ import type { UserModel as OrmUserModel } from '@stacksjs/orm';
3
+ /**
4
+ * Discover and register policies from app/Policies directory
5
+ */
6
+ export declare function discoverPolicies(): Promise<void>;
7
+ /**
8
+ * Register inline gates from Gates.ts
9
+ */
10
+ export declare function registerGates(): Promise<void>;
11
+ /**
12
+ * Initialize authorization system
13
+ */
14
+ export declare function initializeAuthorization(): Promise<void>;
15
+ // Use the row/instance shape from orm so policies operate on the
16
+ // authenticated user object, not the User class constructor.
17
+ declare type UserModel = OrmUserModel;
18
+ export declare abstract class BasePolicy<T = any> {
19
+ before?(user: UserModel | null, ability: string): boolean | null | Promise<boolean | null>;
20
+ viewAny?(user: UserModel | null): boolean | Promise<boolean> | AuthorizationResponse;
21
+ view?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
22
+ create?(user: UserModel | null): boolean | Promise<boolean> | AuthorizationResponse;
23
+ update?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
24
+ delete?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
25
+ restore?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
26
+ forceDelete?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
27
+ protected allow(message?: string): AuthorizationResponse;
28
+ protected deny(message?: string, code?: string): AuthorizationResponse;
29
+ protected denyIf(condition: boolean, message?: string): AuthorizationResponse | boolean;
30
+ protected denyUnless(condition: boolean, message?: string): AuthorizationResponse | boolean;
31
+ protected allowIf(condition: boolean, message?: string): AuthorizationResponse | boolean;
32
+ }
33
+ export { AuthorizationResponse };
package/dist/policy.js ADDED
@@ -0,0 +1,91 @@
1
+ import { AuthorizationResponse } from "./gate";
2
+
3
+ export class BasePolicy {
4
+ allow(message) {
5
+ return AuthorizationResponse.allow(message);
6
+ }
7
+ deny(message, code) {
8
+ return AuthorizationResponse.deny(message, code);
9
+ }
10
+ denyIf(condition, message) {
11
+ if (condition)
12
+ return this.deny(message);
13
+ return !0;
14
+ }
15
+ denyUnless(condition, message) {
16
+ if (!condition)
17
+ return this.deny(message);
18
+ return !0;
19
+ }
20
+ allowIf(condition, message) {
21
+ if (condition)
22
+ return this.allow(message);
23
+ return !1;
24
+ }
25
+ }
26
+ import { log } from "@stacksjs/logging";
27
+ import * as p from "@stacksjs/path";
28
+ import { policy as registerPolicy } from "./gate";
29
+ export async function discoverPolicies() {
30
+ const { fs } = await import("@stacksjs/storage"), policiesDir = p.appPath("Policies");
31
+ if (!fs.existsSync(policiesDir)) {
32
+ log.debug("No Policies directory found");
33
+ return;
34
+ }
35
+ try {
36
+ const mappings = (await import(p.appPath("Gates.ts"))).policies || {};
37
+ for (const [modelName, config] of Object.entries(mappings)) {
38
+ const policyFile = typeof config === "string" ? config : config.policy, policyPath = `${policiesDir}/${policyFile}.ts`;
39
+ if (fs.existsSync(policyPath)) {
40
+ const policyModule = await import(policyPath), PolicyClass = policyModule.default || policyModule[policyFile];
41
+ if (PolicyClass) {
42
+ registerPolicy(modelName, PolicyClass);
43
+ log.debug(`Registered policy: ${policyFile} for ${modelName}`);
44
+ }
45
+ }
46
+ }
47
+ } catch {
48
+ log.debug("No Gates.ts found, using convention-based discovery");
49
+ }
50
+ const policyFiles = fs.readdirSync(policiesDir).filter((file) => file.endsWith("Policy.ts"));
51
+ for (const file of policyFiles) {
52
+ const policyName = file.replace(".ts", ""), modelName = policyName.replace("Policy", ""), policyPath = `${policiesDir}/${file}`;
53
+ try {
54
+ const policyModule = await import(policyPath), PolicyClass = policyModule.default || policyModule[policyName];
55
+ if (PolicyClass) {
56
+ registerPolicy(modelName, PolicyClass);
57
+ log.debug(`Auto-discovered policy: ${policyName} for ${modelName}`);
58
+ }
59
+ } catch (error) {
60
+ log.error(`Failed to load policy ${policyName}:`, error);
61
+ }
62
+ }
63
+ }
64
+ export async function registerGates() {
65
+ const { define, before, after } = await import("./gate");
66
+ try {
67
+ const gatesModule = await import(p.appPath("Gates.ts")), gates = gatesModule.gates || gatesModule.default?.gates || {};
68
+ for (const [ability, callback] of Object.entries(gates))
69
+ if (typeof callback === "function") {
70
+ define(ability, callback);
71
+ log.debug(`Registered gate: ${ability}`);
72
+ }
73
+ const beforeCallbacks = gatesModule.before || gatesModule.default?.before || [];
74
+ for (const callback of beforeCallbacks)
75
+ if (typeof callback === "function")
76
+ before(callback);
77
+ const afterCallbacks = gatesModule.after || gatesModule.default?.after || [];
78
+ for (const callback of afterCallbacks)
79
+ if (typeof callback === "function")
80
+ after(callback);
81
+ log.debug("Gates registered successfully");
82
+ } catch {
83
+ log.debug("No Gates.ts found or failed to load");
84
+ }
85
+ }
86
+ export async function initializeAuthorization() {
87
+ await registerGates();
88
+ await discoverPolicies();
89
+ }
90
+
91
+ export { AuthorizationResponse };
@@ -0,0 +1,44 @@
1
+ export declare interface RateLimitEntry {
2
+ attempts: number
3
+ lockedUntil: number
4
+ }
5
+ /**
6
+ * Pluggable backing store for the auth rate limiter.
7
+ *
8
+ * Methods may be sync or async — the limiter awaits them either way. The
9
+ * default {@link MemoryStore} is process-local (fine for single-instance and
10
+ * dev). On a horizontally-scaled deployment the in-memory store is trivially
11
+ * bypassed by spreading attempts across instances, so production should swap
12
+ * in a shared store via `RateLimiter.useSharedStore()` (cache-backed; becomes
13
+ * cluster-wide when the cache driver is Redis) or a custom `useStore()`.
14
+ */
15
+ export declare interface RateLimiterStore {
16
+ get: (key: string) => Promise<RateLimitEntry | undefined> | RateLimitEntry | undefined
17
+ set: (key: string, entry: RateLimitEntry, ttlMs: number) => Promise<void> | void
18
+ delete: (key: string) => Promise<void> | void
19
+ }
20
+ /** Process-local store — the default. */
21
+ declare class MemoryStore implements RateLimiterStore {
22
+ get(key: string): RateLimitEntry | undefined;
23
+ set(key: string, entry: RateLimitEntry): void;
24
+ delete(key: string): void;
25
+ }
26
+ /**
27
+ * Cache-backed store. Cross-instance when the configured cache driver is
28
+ * Redis; otherwise behaves like an in-memory store with TTL eviction. Entries
29
+ * carry a TTL so attempts decay automatically — no separate eviction pass.
30
+ */
31
+ declare class CacheStore implements RateLimiterStore {
32
+ get(key: string): Promise<RateLimitEntry | undefined>;
33
+ set(key: string, entry: RateLimitEntry, ttlMs: number): Promise<void>;
34
+ delete(key: string): Promise<void>;
35
+ }
36
+ export declare class RateLimiter {
37
+ static useStore(custom: RateLimiterStore): void;
38
+ static useSharedStore(): void;
39
+ static useMemoryStore(): void;
40
+ static isRateLimited(email: string): Promise<boolean>;
41
+ static recordFailedAttempt(email: string): Promise<void>;
42
+ static resetAttempts(email: string): Promise<void>;
43
+ static validateAttempt(email: string): Promise<void>;
44
+ }
@@ -0,0 +1,91 @@
1
+ import { HttpError } from "@stacksjs/error-handling";
2
+ const MAX_ATTEMPTS = 5, LOCKOUT_DURATION = 900000, MAX_STORE_SIZE = 1e4, EVICTION_INTERVAL = 300000;
3
+
4
+ class MemoryStore {
5
+ store = new Map;
6
+ lastEviction = Date.now();
7
+ evict() {
8
+ const now = Date.now(), intervalElapsed = now - this.lastEviction >= EVICTION_INTERVAL, overCapacity = this.store.size >= MAX_STORE_SIZE;
9
+ if (!intervalElapsed && !overCapacity)
10
+ return;
11
+ this.lastEviction = now;
12
+ for (const [key, value] of this.store)
13
+ if (value.lockedUntil > 0 && value.lockedUntil <= now)
14
+ this.store.delete(key);
15
+ else if (value.lockedUntil === 0 && value.attempts === 0)
16
+ this.store.delete(key);
17
+ }
18
+ get(key) {
19
+ this.evict();
20
+ return this.store.get(key);
21
+ }
22
+ set(key, entry) {
23
+ this.store.set(key, entry);
24
+ }
25
+ delete(key) {
26
+ this.store.delete(key);
27
+ }
28
+ }
29
+
30
+ class CacheStore {
31
+ prefix = "auth:ratelimit:";
32
+ async get(key) {
33
+ const { cache } = await import("@stacksjs/cache"), raw = await cache.get(`${this.prefix}${key}`);
34
+ if (raw == null)
35
+ return;
36
+ try {
37
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
38
+ } catch {
39
+ return;
40
+ }
41
+ }
42
+ async set(key, entry, ttlMs) {
43
+ const { cache } = await import("@stacksjs/cache");
44
+ await cache.set(`${this.prefix}${key}`, JSON.stringify(entry), Math.ceil(ttlMs / 1000));
45
+ }
46
+ async delete(key) {
47
+ const { cache } = await import("@stacksjs/cache");
48
+ await cache.remove(`${this.prefix}${key}`);
49
+ }
50
+ }
51
+ let store = new MemoryStore;
52
+
53
+ export class RateLimiter {
54
+ static useStore(custom) {
55
+ store = custom;
56
+ }
57
+ static useSharedStore() {
58
+ store = new CacheStore;
59
+ }
60
+ static useMemoryStore() {
61
+ store = new MemoryStore;
62
+ }
63
+ static async isRateLimited(email) {
64
+ email = email.toLowerCase();
65
+ const now = Date.now(), userAttempts = await store.get(email);
66
+ if (!userAttempts)
67
+ return !1;
68
+ if (userAttempts.lockedUntil > 0 && userAttempts.lockedUntil <= now) {
69
+ await store.delete(email);
70
+ return !1;
71
+ }
72
+ return userAttempts.lockedUntil > 0;
73
+ }
74
+ static async recordFailedAttempt(email) {
75
+ email = email.toLowerCase();
76
+ const now = Date.now(), userAttempts = await store.get(email) || { attempts: 0, lockedUntil: 0 };
77
+ userAttempts.attempts++;
78
+ if (userAttempts.attempts >= MAX_ATTEMPTS) {
79
+ userAttempts.lockedUntil = now + LOCKOUT_DURATION;
80
+ userAttempts.attempts = 0;
81
+ }
82
+ await store.set(email, userAttempts, LOCKOUT_DURATION);
83
+ }
84
+ static async resetAttempts(email) {
85
+ await store.delete(email.toLowerCase());
86
+ }
87
+ static async validateAttempt(email) {
88
+ if (await this.isRateLimited(email))
89
+ throw new HttpError(429, "Too many login attempts. Please try again later.");
90
+ }
91
+ }
@@ -0,0 +1,24 @@
1
+ import type { RoleRecord } from './rbac';
2
+ /**
3
+ * Idempotently seed the three default role packs. Existing role records
4
+ * with the same `(name, guard_name)` are left untouched.
5
+ *
6
+ * Throws only if the underlying `createRole` throws for a reason other
7
+ * than a unique-constraint race (the BqbRbacStore's `swallowDuplicate`
8
+ * already handles that case). Caller should typically log the error and
9
+ * surface it — a missing roles table means the migrations haven't run
10
+ * yet, which is a different bug than a seeder failure.
11
+ */
12
+ export declare function seedDefaultRoles(): Promise<SeedDefaultRolesResult>;
13
+ /**
14
+ * The role packs every Stacks install starts with. `useRole()`'s built-in
15
+ * predicates (`isAdmin`, `isDev`, `isClient`) check these names verbatim,
16
+ * so renaming them in a project effectively unbinds the composable from
17
+ * its defaults — that's fine, but obvious to readers.
18
+ * @defaultValue `[ { name: 'admin', guard_name: 'web', description: 'Full access. Sees every dashboard surface, every model, every infra control.', }, { name: 'dev', guard_name: 'web', description: 'Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management.', }, { name: 'client', guard_name: 'web', description: 'End user / client. Sees content, orders, profile, billing — no dev tools, no infra surfaces.', }, ]`
19
+ */
20
+ export declare const DEFAULT_ROLE_PACKS: Array<{ name: string, guard_name: string, description: string }>;
21
+ export declare interface SeedDefaultRolesResult {
22
+ created: RoleRecord[]
23
+ skipped: Array<{ name: string, guard_name: string, reason: 'already_exists' }>
24
+ }
@@ -0,0 +1,30 @@
1
+ import { createRole, findRole } from "./rbac";
2
+ export const DEFAULT_ROLE_PACKS = [
3
+ {
4
+ name: "admin",
5
+ guard_name: "web",
6
+ description: "Full access. Sees every dashboard surface, every model, every infra control."
7
+ },
8
+ {
9
+ name: "dev",
10
+ guard_name: "web",
11
+ description: "Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management."
12
+ },
13
+ {
14
+ name: "client",
15
+ guard_name: "web",
16
+ description: "End user / client. Sees content, orders, profile, billing \u2014 no dev tools, no infra surfaces."
17
+ }
18
+ ];
19
+ export async function seedDefaultRoles() {
20
+ const created = [], skipped = [];
21
+ for (const pack of DEFAULT_ROLE_PACKS) {
22
+ if (await findRole(pack.name, pack.guard_name)) {
23
+ skipped.push({ name: pack.name, guard_name: pack.guard_name, reason: "already_exists" });
24
+ continue;
25
+ }
26
+ const role = await createRole(pack.name, pack.guard_name, pack.description);
27
+ created.push(role);
28
+ }
29
+ return { created, skipped };
30
+ }