slauthjs 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/users.js CHANGED
@@ -1,51 +1,121 @@
1
- import { base64Url } from './util.js'
1
+ import { randomId } from './util.js'
2
2
 
3
3
  /**
4
- * People. A user is just `{ id, email, createdAt }`; keep anything else about them in
5
- * your own tables, keyed by `user.id`.
4
+ * People.
6
5
  *
7
- * user:<id> the user
6
+ * user:<id> { id, email, name, image, roles, metadata, privateMetadata, createdAt, updatedAt, bannedAt }
8
7
  * email:<email> -> id
9
8
  *
9
+ * `metadata` is yours to fill and is sent to the browser; `privateMetadata` never leaves
10
+ * the server. Anything bigger belongs in your own tables, keyed by `user.id`.
11
+ *
10
12
  * @param {import('./index.js').Store} store
11
- * @param {undefined | string[] | ((email: string) => boolean | Promise<boolean>)} allow
13
+ * @param {undefined | string[] | ((email: string, context: object) => boolean | Promise<boolean>)} allow
12
14
  */
13
15
  export function createUsers(store, allow) {
14
16
  const list = Array.isArray(allow) ? allow.map((e) => e.trim().toLowerCase()) : null
15
17
 
16
- async function allowed(email) {
17
- if (typeof allow === 'function') return Boolean(await allow(email))
18
+ async function allowed(email, context = {}) {
19
+ if (typeof allow === 'function') return Boolean(await allow(email, context))
18
20
  if (!list) return true
19
21
  // "@family.com" lets in anyone at that domain.
20
22
  return list.includes(email) || list.some((entry) => entry.startsWith('@') && email.endsWith(entry))
21
23
  }
22
24
 
25
+ async function byId(id) {
26
+ if (typeof id !== 'string') return null
27
+ const user = await store.get(`user:${id}`)
28
+ return user ? withDefaults(user) : null
29
+ }
30
+
31
+ async function byEmail(email) {
32
+ const id = await store.get(`email:${normalizeEmail(email)}`)
33
+ return id ? byId(id) : null
34
+ }
35
+
36
+ /** Makes a user; no allow-list check, so it's for sign-in flows that already did one, and for admins. */
37
+ async function create({ email: rawEmail, name = null, image = null, roles = [], metadata = {}, privateMetadata = {} }) {
38
+ const email = normalizeEmail(rawEmail)
39
+ if (!validEmail(email)) throw new Error(`slauth: "${rawEmail}" isn't an email address.`)
40
+ if (await store.get(`email:${email}`)) throw new Error(`slauth: there's already a user with ${email}.`)
41
+ const now = Date.now()
42
+ const user = { id: randomId('usr'), email, name, image, roles, metadata, privateMetadata, createdAt: now, updatedAt: now, bannedAt: null }
43
+ await store.put(`user:${user.id}`, user)
44
+ await store.put(`email:${email}`, user.id)
45
+ return user
46
+ }
47
+
48
+ /** Changes what's given; `email` moves the email index too. Returns the new user, or null. */
49
+ async function update(id, patch) {
50
+ const user = await byId(id)
51
+ if (!user) return null
52
+ const next = { ...user, updatedAt: Date.now() }
53
+ for (const key of ['name', 'image', 'roles', 'metadata', 'privateMetadata', 'bannedAt']) if (patch[key] !== undefined) next[key] = patch[key]
54
+ if (patch.email !== undefined) {
55
+ const email = normalizeEmail(patch.email)
56
+ if (!validEmail(email)) throw new Error(`slauth: "${patch.email}" isn't an email address.`)
57
+ if (email !== user.email) {
58
+ const owner = await store.get(`email:${email}`)
59
+ if (owner && owner !== id) throw new Error(`slauth: there's already a user with ${email}.`)
60
+ await store.put(`email:${email}`, id)
61
+ await store.delete(`email:${user.email}`)
62
+ next.email = email
63
+ }
64
+ }
65
+ await store.put(`user:${id}`, next)
66
+ return next
67
+ }
68
+
23
69
  return {
24
70
  allowed,
71
+ byId,
72
+ byEmail,
73
+ create,
74
+ update,
25
75
 
26
- async byId(id) {
27
- return store.get(`user:${id}`)
76
+ /**
77
+ * The user with this email, made on first sign-in. Null when they aren't allowed in.
78
+ * `profile` fills in a name and picture the first time (e.g. from GitHub).
79
+ */
80
+ async signIn(rawEmail, context = {}, profile = {}) {
81
+ const email = normalizeEmail(rawEmail)
82
+ if (!(await allowed(email, context))) return null
83
+ const existing = await byEmail(email)
84
+ if (existing) {
85
+ const fill = {}
86
+ if (!existing.name && profile.name) fill.name = profile.name
87
+ if (!existing.image && profile.image) fill.image = profile.image
88
+ return Object.keys(fill).length ? update(existing.id, fill) : existing
89
+ }
90
+ const user = await create({ email, name: profile.name ?? null, image: profile.image ?? null })
91
+ return { ...user, isNew: true }
28
92
  },
29
93
 
30
- async byEmail(email) {
31
- const id = await store.get(`email:${normalizeEmail(email)}`)
32
- return id ? store.get(`user:${id}`) : null
94
+ /** Forgets the user record and email; core.js removes everything hanging off it. */
95
+ async remove(user) {
96
+ await store.delete(`email:${user.email}`)
97
+ await store.delete(`user:${user.id}`)
33
98
  },
34
99
 
35
- /** The user with this email, created on first sign-in. Null when they aren't allowed in. */
36
- async signIn(rawEmail) {
37
- const email = normalizeEmail(rawEmail)
38
- if (!(await allowed(email))) return null
39
- const existing = await this.byEmail(email)
40
- if (existing) return existing
41
- const user = { id: `usr_${base64Url(crypto.getRandomValues(new Uint8Array(15)))}`, email, createdAt: Date.now() }
42
- await store.put(`user:${user.id}`, user)
43
- await store.put(`email:${email}`, user.id)
44
- return user
100
+ /** Every user, a page at a time. Needs a store with list() (all the built-in ones). */
101
+ async list({ limit = 100, cursor } = {}) {
102
+ if (typeof store.list !== 'function') throw new Error("slauth: listing users needs a store with list(); the built-in stores all have one.")
103
+ const page = await store.list('user:', { limit, cursor })
104
+ return { users: page.items.map((item) => withDefaults(item.value)), cursor: page.cursor }
45
105
  },
46
106
  }
47
107
  }
48
108
 
109
+ /** Users saved by slauth 1.0 only had { id, email, createdAt }. */
110
+ function withDefaults(user) {
111
+ return { name: null, image: null, roles: [], metadata: {}, privateMetadata: {}, updatedAt: user.createdAt, bannedAt: null, ...user }
112
+ }
113
+
114
+ /** What the browser (and your app, via auth.user) sees. */
115
+ export function publicUser(user) {
116
+ return { id: user.id, email: user.email, name: user.name ?? null, image: user.image ?? null, roles: user.roles ?? [], metadata: user.metadata ?? {}, createdAt: user.createdAt }
117
+ }
118
+
49
119
  export function normalizeEmail(email) {
50
120
  return String(email ?? '').trim().toLowerCase()
51
121
  }
package/src/util.js CHANGED
@@ -67,3 +67,40 @@ export function concat(...parts) {
67
67
  }
68
68
  return out
69
69
  }
70
+
71
+ /** A random, URL-safe id with a readable prefix, e.g. id('usr') → "usr_k3J…". */
72
+ export function randomId(prefix, bytes = 15) {
73
+ return `${prefix}_${base64Url(crypto.getRandomValues(new Uint8Array(bytes)))}`
74
+ }
75
+
76
+ /** A 302 to `location`, with any Set-Cookie values. */
77
+ export function redirect(location, cookies = []) {
78
+ const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' })
79
+ for (const c of cookies) headers.append('Set-Cookie', c)
80
+ return new Response(null, { status: 302, headers })
81
+ }
82
+
83
+ /**
84
+ * Only paths on this site ("/dashboard?tab=1"), never "//evil.example" or
85
+ * "https://evil.example", so a sign-in link can't send someone elsewhere.
86
+ */
87
+ export function safeRedirect(path, fallback = '/') {
88
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\') || /[\r\n]/.test(path)) return fallback
89
+ return path
90
+ }
91
+
92
+ /** Adds query parameters to a same-site path. */
93
+ export function withParams(path, params) {
94
+ const url = new URL(path, 'http://x')
95
+ for (const [key, value] of Object.entries(params)) if (value != null) url.searchParams.set(key, value)
96
+ return url.pathname + url.search + url.hash
97
+ }
98
+
99
+ /** The visitor's IP from the headers hosts set for it, or null when there's none we can trust. */
100
+ export function clientIp(request) {
101
+ return request.headers.get('cf-connecting-ip') ?? request.headers.get('fly-client-ip') ?? request.headers.get('x-real-ip') ?? null
102
+ }
103
+
104
+ export function escapeHtml(text) {
105
+ return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
106
+ }