@stacksjs/auth 0.72.101 → 0.72.103

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/dist/gate.d.ts CHANGED
@@ -43,7 +43,7 @@ export declare function defineGates<const T extends GatesDefinition>(definition:
43
43
  * define('edit-settings', (user) => user?.isAdmin)
44
44
  * define('update-post', (user, post) => user?.id === post.userId)
45
45
  */
46
- export declare function define<T = any>(ability: string, callback: GateCallback<T>): void;
46
+ export declare function define<T = any>(ability: Ability, callback: GateCallback<T>): void;
47
47
  /**
48
48
  * Register a policy for a model
49
49
  *
@@ -73,28 +73,34 @@ export declare function after(callback: GateAfterCallback): void;
73
73
  * if (await allows('edit-settings', user)) { ... }
74
74
  * if (await allows('update', user, post)) { ... }
75
75
  */
76
- export declare function allows(ability: Ability, user: UserModel | null, ...args: any[]): Promise<boolean>;
76
+ export declare function allows<const A extends Ability>(ability: A, user: UserModel | null, ...args: AbilityArgs<A>): Promise<boolean>;
77
77
  /**
78
78
  * Check if the user is denied from performing an ability
79
79
  *
80
80
  * @example
81
81
  * if (await denies('delete', user, post)) { ... }
82
82
  */
83
- export declare function denies(ability: Ability, user: UserModel | null, ...args: any[]): Promise<boolean>;
83
+ export declare function denies<const A extends Ability>(ability: A, user: UserModel | null, ...args: AbilityArgs<A>): Promise<boolean>;
84
84
  /**
85
85
  * Check if the user can perform an ability (alias for allows)
86
86
  */
87
- export declare function can(ability: Ability, user: UserModel | null, ...args: any[]): Promise<boolean>;
87
+ export declare function can<const A extends Ability>(ability: A, user: UserModel | null, ...args: AbilityArgs<A>): Promise<boolean>;
88
88
  /**
89
89
  * Check if the user cannot perform an ability (alias for denies)
90
90
  */
91
- export declare function cannot(ability: Ability, user: UserModel | null, ...args: any[]): Promise<boolean>;
91
+ export declare function cannot<const A extends Ability>(ability: A, user: UserModel | null, ...args: AbilityArgs<A>): Promise<boolean>;
92
92
  /**
93
93
  * Check if the user can perform any of the given abilities
94
94
  *
95
95
  * @example
96
96
  * if (await any(['update', 'delete'], user, post)) { ... }
97
97
  */
98
+ /*
99
+ * `any` / `all` / `none` keep `any[]`: one argument list is checked against
100
+ * SEVERAL abilities, which may each declare different parameters, so there is
101
+ * no single tuple that is correct for the call. The single-ability functions
102
+ * above are where the declaration can be enforced.
103
+ */
98
104
  export declare function any(abilities: readonly Ability[], user: UserModel | null, ...args: any[]): Promise<boolean>;
99
105
  /**
100
106
  * Check if the user can perform all of the given abilities
@@ -113,11 +119,11 @@ export declare function none(abilities: readonly Ability[], user: UserModel | nu
113
119
  * @example
114
120
  * await authorize('update', user, post) // Throws if not allowed
115
121
  */
116
- export declare function authorize(ability: Ability, user: UserModel | null, ...args: any[]): Promise<AuthorizationResponse>;
122
+ export declare function authorize<const A extends Ability>(ability: A, user: UserModel | null, ...args: AbilityArgs<A>): Promise<AuthorizationResponse>;
117
123
  /**
118
124
  * Get detailed inspection result for an ability check
119
125
  */
120
- export declare function inspect(ability: Ability, user: UserModel | null, ...args: any[]): Promise<AuthorizationResponse>;
126
+ export declare function inspect<const A extends Ability>(ability: A, user: UserModel | null, ...args: AbilityArgs<A>): Promise<AuthorizationResponse>;
121
127
  /**
122
128
  * Get a policy instance for a model
123
129
  */
@@ -246,6 +252,17 @@ export type PolicyAbility = 'viewAny' | 'view' | 'create' | 'update' | 'delete'
246
252
  */
247
253
  // eslint-disable-next-line ts/ban-types -- `string & {}` keeps literal completions alive
248
254
  export type Ability = GateName | PolicyAbility | (string & {});
255
+ /**
256
+ * The arguments an ability takes after the user.
257
+ *
258
+ * A gate the application declares contributes its own parameter list, so
259
+ * `Gate.allows('update-post', user, post)` is checked against how the gate was
260
+ * written. Anything else - a policy ability, or a name computed at runtime -
261
+ * keeps `any[]`, which is what it was for every ability before.
262
+ */
263
+ export type AbilityArgs<A extends Ability> = A extends keyof AppGates
264
+ ? (AppGates[A] extends readonly unknown[] ? AppGates[A] : any[])
265
+ : any[];
249
266
  /** A policy class name, as narrow as the application has made it. */
250
267
  export type PolicyName = keyof PolicyClasses extends never ? string : keyof PolicyClasses & string;
251
268
  /** A model name a policy may be registered for. */
@@ -1,3 +1,3 @@
1
- import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await template("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
1
+ import{createHash,randomBytes}from"node:crypto";import process from"node:process";import{config}from"@stacksjs/config";import{db,sqlDateTime}from"@stacksjs/database";import{mail,templateByName}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{RateLimiter}from"./rate-limiter";function hashToken(raw){return createHash("sha256").update(raw).digest("hex")}function expireMinutes(){return config.auth.magicLink?.expire??15}function safeRedirect(candidate){const fallback=config.auth.magicLink?.redirectDefault??"/";if(!candidate)return fallback;if(!candidate.startsWith("/")||candidate.startsWith("//"))return fallback;return candidate}async function linkBase(siteId){if(siteId){const primary=await db.selectFrom("site_domains").where("site_id","=",siteId).where("is_primary","=",!0).where("verified_at","is not",null).select(["domain"]).executeTakeFirst();if(primary?.domain)return`https://${primary.domain}`;const site=await db.selectFrom("sites").where("id","=",siteId).select(["subdomain"]).executeTakeFirst(),base=config.sites?.baseDomain;if(site?.subdomain&&base)return`https://${site.subdomain}.${base}`}return config.app.url?`https://${config.app.url}`:`http://localhost:${process.env.PORT||"3000"}`}export async function sendMagicLink(email,options={}){const normalized=email.trim().toLowerCase();if(await RateLimiter.isRateLimited(normalized))return;await RateLimiter.recordFailedAttempt(normalized);let user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst();if(!user&&options.createUser){await db.insertInto("users").values({email:normalized,name:normalized.split("@")[0],password:null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();user=await db.selectFrom("users").where("email","=",normalized).select(["id","email"]).executeTakeFirst()}if(!user)return;const raw=randomBytes(32).toString("base64url"),ttl=options.ttlMinutes??expireMinutes(),redirectTo=safeRedirect(options.redirectTo);await db.deleteFrom("magic_link_tokens").where("email","=",normalized).where("consumed_at","is",null).execute();await db.insertInto("magic_link_tokens").values({email:normalized,user_id:user.id,token:hashToken(raw),expires_at:sqlDateTime(new Date(Date.now()+ttl*60000)),redirect_to:redirectTo,site_id:options.siteId??null,created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute();const base=await linkBase(options.siteId),filled=(config.auth.magicLink?.url??"/auth/magic/{token}").replace("{token}",raw),linkUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`,appName=config.app.name||"Stacks";let html,text;try{const rendered=await templateByName("magic-link",{subject:`Sign in to ${appName}`,variables:{linkUrl,expireMinutes:ttl}});if(rendered.html||rendered.text){html=rendered.html;text=rendered.text}}catch{}try{await mail.sendOrFail({to:normalized,subject:`Sign in to ${appName}`,...html?{html}:{},text:text||`Sign in to ${appName}: ${linkUrl}
2
2
 
3
3
  This link works once and expires in ${ttl} minutes. If you didn't request it, ignore this email.`})}catch(error){log.error(`Magic-link email to ${normalized} failed: ${error.message}`)}}export async function consumeMagicLink(raw){if(!raw||raw.length>255)return{ok:!1,reason:"invalid"};const hashed=hashToken(raw),now=sqlDateTime(new Date),claim=await db.updateTable("magic_link_tokens").set({consumed_at:now,updated_at:now}).where("token","=",hashed).where("consumed_at","is",null).where("expires_at",">",now).execute(),updated=typeof claim==="number"?claim:Number(claim?.numUpdatedRows??claim?.changes??0),row=await db.selectFrom("magic_link_tokens").where("token","=",hashed).select(["user_id","email","expires_at","consumed_at","redirect_to"]).executeTakeFirst();if(!row)return{ok:!1,reason:"invalid"};if(updated<1){if(!row.consumed_at&&new Date(row.expires_at).getTime()<=Date.now())return{ok:!1,reason:"expired"};return{ok:!1,reason:"used"}}if(!row.user_id)return{ok:!1,reason:"no-user"};return{ok:!0,userId:Number(row.user_id),email:row.email,redirectTo:safeRedirect(row.redirect_to)}}export async function pruneMagicLinkTokens(olderThanDays=7){const cutoff=sqlDateTime(new Date(Date.now()-olderThanDays*86400000));await db.deleteFrom("magic_link_tokens").where("expires_at","<",cutoff).execute()}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.101",
5
+ "version": "0.72.103",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "better-dx": "^0.2.24",
65
- "@stacksjs/error-handling": "0.72.101",
66
- "@stacksjs/router": "0.72.101"
65
+ "@stacksjs/error-handling": "0.72.103",
66
+ "@stacksjs/router": "0.72.103"
67
67
  }
68
68
  }