@haruhimemoe/next-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/auth/create.d.ts +204 -0
- package/dist/auth/create.js +102 -0
- package/dist/auth/index.d.ts +13 -0
- package/dist/auth/index.js +13 -0
- package/dist/auth/indexes.d.ts +23 -0
- package/dist/auth/indexes.js +44 -0
- package/dist/auth/osu-id.d.ts +10 -0
- package/dist/auth/osu-id.js +10 -0
- package/dist/auth/osu.d.ts +80 -0
- package/dist/auth/osu.js +67 -0
- package/dist/auth/session.d.ts +48 -0
- package/dist/auth/session.js +32 -0
- package/dist/auth-react/RestoreSignedIn.d.ts +33 -0
- package/dist/auth-react/RestoreSignedIn.js +38 -0
- package/dist/auth-react/account-store.d.ts +55 -0
- package/dist/auth-react/account-store.js +78 -0
- package/dist/auth-react/index.d.ts +17 -0
- package/dist/auth-react/index.js +17 -0
- package/dist/auth-react/marker.d.ts +35 -0
- package/dist/auth-react/marker.js +30 -0
- package/dist/auth-react/sign-in.d.ts +24 -0
- package/dist/auth-react/sign-in.js +24 -0
- package/dist/auth-react/use-account.d.ts +52 -0
- package/dist/auth-react/use-account.js +52 -0
- package/dist/env/errors.d.ts +18 -0
- package/dist/env/errors.js +21 -0
- package/dist/env/index.d.ts +13 -0
- package/dist/env/index.js +13 -0
- package/dist/env/optional.d.ts +54 -0
- package/dist/env/optional.js +84 -0
- package/dist/env/osu-app.d.ts +24 -0
- package/dist/env/osu-app.js +32 -0
- package/dist/env/server-env.d.ts +63 -0
- package/dist/env/server-env.js +88 -0
- package/dist/mongo/client.d.ts +56 -0
- package/dist/mongo/client.js +72 -0
- package/dist/mongo/collections.d.ts +16 -0
- package/dist/mongo/collections.js +29 -0
- package/dist/mongo/duplicate.d.ts +16 -0
- package/dist/mongo/duplicate.js +16 -0
- package/dist/mongo/index.d.ts +13 -0
- package/dist/mongo/index.js +13 -0
- package/dist/mongo/indexes.d.ts +55 -0
- package/dist/mongo/indexes.js +81 -0
- package/dist/server/body.d.ts +57 -0
- package/dist/server/body.js +74 -0
- package/dist/server/budget.d.ts +39 -0
- package/dist/server/budget.js +46 -0
- package/dist/server/client-ip.d.ts +30 -0
- package/dist/server/client-ip.js +92 -0
- package/dist/server/counter.d.ts +80 -0
- package/dist/server/counter.js +76 -0
- package/dist/server/cross-site.d.ts +32 -0
- package/dist/server/cross-site.js +36 -0
- package/dist/server/errors.d.ts +57 -0
- package/dist/server/errors.js +55 -0
- package/dist/server/index.d.ts +20 -0
- package/dist/server/index.js +20 -0
- package/dist/server/machine-auth.d.ts +55 -0
- package/dist/server/machine-auth.js +74 -0
- package/dist/server/rate-limit.d.ts +75 -0
- package/dist/server/rate-limit.js +101 -0
- package/dist/server/safe-next.d.ts +36 -0
- package/dist/server/safe-next.js +42 -0
- package/dist/server/security-txt.d.ts +32 -0
- package/dist/server/security-txt.js +31 -0
- package/dist/testing/env.d.ts +29 -0
- package/dist/testing/env.js +33 -0
- package/dist/testing/index.d.ts +13 -0
- package/dist/testing/index.js +13 -0
- package/dist/testing/mongo.d.ts +44 -0
- package/dist/testing/mongo.js +46 -0
- package/dist/testing/msw.d.ts +18 -0
- package/dist/testing/msw.js +24 -0
- package/package.json +140 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@haruhimemoe/next-kit` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `@haruhimemoe/next-kit/server`: `jsonError`, `ERROR_CODES`, `noStore`, `parseJsonBody` with a per-route cap, `parseIdList`, `refuseCrossSite`, `clientIp` and `rateLimitSubject`, `createRateLimiter` and `createBudget` over one MongoDB counter shape, `refuseWithoutBearer` and `sameSecret` for machine routes, `safeNextPath`, `signInHref` and `buildSecurityTxt`. Moved from packs.haruhime.moe and pools.haruhime.moe, taking pools' behavior where they differed.
|
|
14
|
+
- `@haruhimemoe/next-kit/env`: `createServerEnv` with SKIP_ENV_VALIDATION and the production placeholder guard, the osu! app's five variables, and per-call readers (`optionalSecret`, `readIdSet`, `readFlag`, `readOrigin`).
|
|
15
|
+
- `@haruhimemoe/next-kit/mongo`: `createMongo` (one client per process, Mongoose on the same client, an `onConnect` hook), `ensureIndexes` that skips and logs an index existing duplicates break, `ttlIndex` and `defineCollections`.
|
|
16
|
+
- `@haruhimemoe/next-kit/auth`: `createOsuAuth` (better-auth with osu! genericOAuth, no stored osu! tokens, trusted account linking, errors to the sign-in page, the signed-in marker cookie, guard hooks and extra user fields), `AUTH_INDEX_SPECS` and `getOsuUser`.
|
|
17
|
+
- `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
|
|
18
|
+
- `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
|
|
19
|
+
|
|
20
|
+
[unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.1.0...HEAD
|
|
21
|
+
[0.1.0]: https://github.com/haruhimemoe/next-kit/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David (@dvhsh)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<p align="center"><a href="https://github.com/haruhimemoe/next-kit"><picture><source media="(prefers-color-scheme: light)" srcset="https://www.haruhime.moe/brand/repos/next-kit-banner-on-light.svg"><img alt="@haruhimemoe/next-kit" src="https://www.haruhime.moe/brand/repos/next-kit-banner.svg" width="640"></picture></a></p>
|
|
2
|
+
|
|
3
|
+
# @haruhimemoe/next-kit
|
|
4
|
+
|
|
5
|
+
The Next.js server plumbing the haruhime.moe tools share. [packs.haruhime.moe](https://packs.haruhime.moe) and [pools.haruhime.moe](https://pools.haruhime.moe) (in beta) each had their own copy, and the copies had started to drift.
|
|
6
|
+
|
|
7
|
+
- **`/server`:** route helpers. JSON error answers, a JSON body parser with a size cap, a cross-site guard, the client IP, fixed-window rate limits and call budgets in MongoDB, bearer machine auth, where to go after sign-in, and security.txt.
|
|
8
|
+
- **`/env`:** zod env parsing that runs on first use, with SKIP_ENV_VALIDATION for CI and a guard that refuses placeholder secrets on a production server.
|
|
9
|
+
- **`/mongo`:** one MongoClient per process with Mongoose on the same client, index builds that never take the site down, and frozen collection names.
|
|
10
|
+
- **`/auth`:** better-auth with osu! as the only way in, the indexes its collections need, and reading the caller.
|
|
11
|
+
- **`/auth-react`:** the browser half: the signed-in marker cookie, the account store and `useAccount`, and `RestoreSignedIn`.
|
|
12
|
+
- **`/testing`:** Vitest helpers: one in-memory MongoDB per run, an msw server that refuses unhandled requests, and a fake env.
|
|
13
|
+
|
|
14
|
+
Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun add @haruhimemoe/next-kit zod
|
|
20
|
+
# or: npm install @haruhimemoe/next-kit zod
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`zod` 4.6.5 or later in 4.x is a peer dependency. The other peers are optional, and each subpath needs only its own:
|
|
24
|
+
|
|
25
|
+
| Subpath | Needs |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `server` | `mongodb` types for the rate limiter and budget |
|
|
28
|
+
| `env` | nothing else |
|
|
29
|
+
| `mongo` | `mongodb` ^7.6.0, `mongoose` ^9.10.2 |
|
|
30
|
+
| `auth` | `better-auth` ^1.7.5, `mongodb`, `@haruhimemoe/osu` 0.2 or 0.3 |
|
|
31
|
+
| `auth-react` | `react` ^19.3.0, `next` ^16.3.6 |
|
|
32
|
+
| `testing` | `vitest` ^5.0.1, `msw` ^2.15.0, `mongodb-memory-server` ^11.3.0 |
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
One file per app wires each piece to its own names.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// src/env.ts
|
|
40
|
+
import { createServerEnv, OSU_APP_PLACEHOLDERS, OSU_APP_SECRET_KEYS, osuAppEnvSchema, readIdSet } from "@haruhimemoe/next-kit/env";
|
|
41
|
+
|
|
42
|
+
export const serverEnv = createServerEnv({ schema: osuAppEnvSchema, placeholders: OSU_APP_PLACEHOLDERS, secretKeys: OSU_APP_SECRET_KEYS });
|
|
43
|
+
export const getServerEnv = serverEnv.get;
|
|
44
|
+
export const getDatabaseUri = () => serverEnv.pick(process.env, ["MONGODB_URI"]).MONGODB_URI;
|
|
45
|
+
export const getAdminOsuIds = () => readIdSet("ADMIN_OSU_IDS");
|
|
46
|
+
|
|
47
|
+
// src/lib/db.ts
|
|
48
|
+
import { createMongo, ensureIndexes } from "@haruhimemoe/next-kit/mongo";
|
|
49
|
+
import { AUTH_INDEX_SPECS } from "@haruhimemoe/next-kit/auth";
|
|
50
|
+
import { counterTtlIndex } from "@haruhimemoe/next-kit/server";
|
|
51
|
+
|
|
52
|
+
export const { getDb, getMongoClient, getModelConnection, connectDb, connectedDb, closeDb } = createMongo({
|
|
53
|
+
dbName: "pools",
|
|
54
|
+
globalKey: "__poolsMongo",
|
|
55
|
+
uri: getDatabaseUri,
|
|
56
|
+
onConnect: async (db) => {
|
|
57
|
+
await ensureIndexes(db, [...AUTH_INDEX_SPECS, counterTtlIndex()]);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// src/lib/rate-limit.ts
|
|
62
|
+
import { createRateLimiter } from "@haruhimemoe/next-kit/server";
|
|
63
|
+
|
|
64
|
+
export const limiter = createRateLimiter({ db: connectedDb });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A route handler then reads like this:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { clientIp, jsonError, parseJsonBody, rateLimitSubject, refuseCrossSite } from "@haruhimemoe/next-kit/server";
|
|
71
|
+
|
|
72
|
+
export async function POST(request: Request) {
|
|
73
|
+
const foreign = refuseCrossSite(request, { siteUrl: SITE.url, siteTitle: SITE.title });
|
|
74
|
+
if (foreign) return foreign;
|
|
75
|
+
const limited = await limiter.refuseOverLimit(RATE_LIMITS.save, rateLimitSubject(clientIp(request.headers)));
|
|
76
|
+
if (limited) return limited;
|
|
77
|
+
const body = await parseJsonBody(request, saveBodySchema);
|
|
78
|
+
if (!body.ok) return body.response;
|
|
79
|
+
// ...
|
|
80
|
+
return jsonError(404, "Pack not found.");
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Sign-in takes a server file and a client file:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// src/lib/auth.ts (server)
|
|
88
|
+
import { createOsuAuth, getOsuUser } from "@haruhimemoe/next-kit/auth";
|
|
89
|
+
|
|
90
|
+
export const getAuth = () => {
|
|
91
|
+
const env = getServerEnv();
|
|
92
|
+
return createOsuAuth({
|
|
93
|
+
clientId: env.OSU_CLIENT_ID, clientSecret: env.OSU_CLIENT_SECRET,
|
|
94
|
+
baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET,
|
|
95
|
+
db: getDb(), client: getMongoClient(),
|
|
96
|
+
markerCookie: "pools-signed-in",
|
|
97
|
+
hooks: { afterUserCreate: linkNewEditor },
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/lib/account.ts (browser)
|
|
102
|
+
"use client";
|
|
103
|
+
import { createAccount, createSignedInMarker } from "@haruhimemoe/next-kit/auth-react";
|
|
104
|
+
|
|
105
|
+
export const marker = createSignedInMarker("pools-signed-in");
|
|
106
|
+
export const { store, useAccount, markSignedOut, RestoreSignedIn } = createAccount(authClient, marker);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Build the auth instance once (memoize `getAuth`), and use the same cookie name on both sides.
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
### server
|
|
114
|
+
|
|
115
|
+
| Export | What it does |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `jsonError(status, message, code?)` | `{ error: { code, message } }` JSON; the code comes from `ERROR_CODES` unless given. |
|
|
118
|
+
| `ERROR_CODES`, `errorCodeFor(status)` | Stable machine codes per status; unknown statuses fall back to `internal_error` (5xx) or `bad_request`. |
|
|
119
|
+
| `noStore(response)`, `withHeaders(response, headers)` | Set `Cache-Control: no-store`, or any headers, on the same response. |
|
|
120
|
+
| `parseJsonBody(request, schema, { maxBytes?, tooLarge? })` | `{ ok: true, data }` or `{ ok: false, response }`: 415 unless JSON, 413 past the cap (16 KB by default, `MAX_BODY_BYTES`), 400 for bad JSON or a schema refusal. A refinement's `params.code` becomes the error code. Pass `z.strictObject` to refuse unknown keys. |
|
|
121
|
+
| `parseIdList(raw, { max, isValid? })` | `?ids=1,2,3` as numbers, or null. Each part is 1 to 10 plain digits; the default check is 1 to `MAX_ID`. |
|
|
122
|
+
| `refuseCrossSite(request, { siteUrl, siteTitle })` | A 403 when Origin is foreign or Sec-Fetch-Site says cross-site or same-site, else null. |
|
|
123
|
+
| `clientIp(headers)`, `rateLimitSubject(ip)` | Vercel's x-real-ip or first x-forwarded-for, and the counter subject (IPv6 by its /64). |
|
|
124
|
+
| `createRateLimiter({ db, collection?, now? })` | `hit(rule, subject, cost?)`, `refuseOverLimit(rule, subject, cost?)` (a no-store 429 or null) and `deleteSubject(rules, subject)`. Counting fails open and logs. |
|
|
125
|
+
| `createBudget({ db, global, perSubject?, globalSubject? })` | `take(subject?, nowMs?)` and `gate(subject?)`, a `beforeCall` for `@haruhimemoe/osu` that stays no after its first no. |
|
|
126
|
+
| `windowFor`, `rateLimitId`, `rateLimitHeaders`, `retryText`, `tooManyRequests`, `unlimited`, `userSubject` | The fixed-window math, the counter id, RateLimit-* headers, the 429, a result with nothing counted, and `osu:<osuId>`. |
|
|
127
|
+
| `counterTtlIndex(collection?)`, `RATE_LIMITS_COLLECTION` | The TTL index that removes spent counters, and the default collection. |
|
|
128
|
+
| `refuseWithoutBearer(request, { secret, label, notConfigured, failures?, noStore? })` | Machine auth: null for the right `Bearer` secret, else 503 `not_configured`, 401, or 429 when failures are counted. |
|
|
129
|
+
| `sameSecret(given, secret)`, `bearerToken(headers)` | SHA-256 digests compared with `timingSafeEqual`, and the token after `Bearer `. |
|
|
130
|
+
| `safeNextPath(raw, { fallback, signInPath? })`, `signInHref(next, signInPath?)` | A same-site path to go to after sign-in (never the sign-in page), and the link carrying it. |
|
|
131
|
+
| `buildSecurityTxt({ contactEmail, siteUrl, policyUrl, now })` | The RFC 9116 body, expiring a year after `now`. |
|
|
132
|
+
|
|
133
|
+
### env
|
|
134
|
+
|
|
135
|
+
| Export | What it does |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `createServerEnv({ schema, placeholders, secretKeys })` | `parse(source)`, `pick(source, keys)`, `get()` (memoized), `reset()` and `assertNoPlaceholderSecrets(source, keys?)`. Errors name variables and never print values. |
|
|
138
|
+
| `osuAppEnvSchema`, `OSU_APP_PLACEHOLDERS`, `OSU_APP_SECRET_KEYS` | The five variables both apps need: `MONGODB_URI`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `OSU_CLIENT_ID`, `OSU_CLIENT_SECRET`. |
|
|
139
|
+
| `optionalSecret(key, minLength)`, `readIdSet(key)`, `readFlag(key)`, `readOrigin(key, fallback)`, `readOptional(key)` | Read on every call, never memoized, so a bad value breaks only what uses it. |
|
|
140
|
+
| `isProductionServer(source)`, `isEnvValidationSkipped(source?)`, `EnvError`, `invalidEnv(keys)`, `BUILD_PHASE` | The production rule (VERCEL_ENV when set, else NODE_ENV, never during `next build`) and the error. |
|
|
141
|
+
|
|
142
|
+
### mongo
|
|
143
|
+
|
|
144
|
+
| Export | What it does |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `createMongo({ dbName, globalKey, uri, maxPoolSize?, serverSelectionTimeoutMS?, onConnect? })` | `getMongoClient`, `getDb`, `getModelConnection`, `connectDb`, `connectedDb`, `closeDb`. 5 connections and a 5 s timeout by default; a failed connect is retried next call. |
|
|
147
|
+
| `ensureIndexes(db, specs)` | Builds each `IndexSpec` on its own and returns `{ built, skipped }`. A unique index that existing duplicates break is skipped and logged with the duplicate keys (never for `secret: true`). Never throws. |
|
|
148
|
+
| `ttlIndex(collection, field, seconds?, name?)`, `indexName(spec)` | A TTL index spec, and the name MongoDB gives an index. |
|
|
149
|
+
| `defineCollections(names)` | Frozen collection-name constants; throws on an invalid or repeated name. |
|
|
150
|
+
| `isDuplicateKeyError(error)`, `DUPLICATE_KEY` | E11000. |
|
|
151
|
+
|
|
152
|
+
### auth
|
|
153
|
+
|
|
154
|
+
| Export | What it does |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `createOsuAuth({ clientId, clientSecret, baseURL, secret, db, client, markerCookie, signInPath?, hooks?, userFields? })` | better-auth on MongoDB with osu! genericOAuth (identify + public, PKCE). `/update-user` is off, osu! tokens are never stored, osu! is trusted for account linking, API errors land on `/signin?error=<code>`, and the marker cookie follows the session. |
|
|
157
|
+
| `hooks` | `beforeUserCreate`, `beforeAccountCreate` and `beforeSessionCreate` refuse a write by returning false; `afterUserCreate` runs after a new user and never fails the sign-in. |
|
|
158
|
+
| `AUTH_INDEX_SPECS`, `AUTH_INDEXES` | One user per osu! id, one account per osu! link, one session per token, sessions by user, and the session TTL. Pass them to `ensureIndexes`. |
|
|
159
|
+
| `getOsuUser(auth, headers)`, `toSessionUser(session)` | `{ id, osuId, username, avatarUrl }` for the caller, or null. |
|
|
160
|
+
| `osuProvider`, `osuProfileToUser`, `withoutTokens`, `OSU_PROVIDER_ID`, `OSU_USER_FIELDS` | The pieces `createOsuAuth` is built from. |
|
|
161
|
+
|
|
162
|
+
### auth-react
|
|
163
|
+
|
|
164
|
+
| Export | What it does |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `createSignedInMarker(name)`, `markerMaxAge(expiresAt)` | `has(cookieHeader)` and `clear()` for the readable marker cookie. It holds no secret. |
|
|
167
|
+
| `createAccount(authClient, marker)` | A page-wide store, `useAccount()`, `markSignedOut()` and a `RestoreSignedIn` with both bound. Export them from a `"use client"` module. |
|
|
168
|
+
| `createAccountStore(deps)`, `useAccount(store)`, `sessionFetcher(client)` | The store underneath: one session request per page load, only with the marker, and other tabs caught up when this one is shown again. |
|
|
169
|
+
| `RestoreSignedIn({ store, hasMarker, next?, pending? })` | Asks for the session once when the marker is missing; with `next`, goes there after. |
|
|
170
|
+
| `osuSignIn(next, signInPath?)` | The body for `authClient.signIn.social`: an error comes back to `/signin?next=<next>`. |
|
|
171
|
+
| `safeNextPath`, `signInHref`, `OSU_PROVIDER_ID` | The same as in `server` and `auth`, safe in the browser. |
|
|
172
|
+
|
|
173
|
+
### testing
|
|
174
|
+
|
|
175
|
+
| Export | What it does |
|
|
176
|
+
| --- | --- |
|
|
177
|
+
| `startMemoryMongo(project)` | A Vitest globalSetup: one mongodb-memory-server for the run, its URI as `inject("mongoUri")`. |
|
|
178
|
+
| `setupTestDb({ connect, db, close, collections })` | Empties the collections before each test, closes after the file. `BETTER_AUTH_COLLECTIONS` lists better-auth's. |
|
|
179
|
+
| `setupMsw(...handlers)` | An msw server for the file; an unhandled request is an error. |
|
|
180
|
+
| `TEST_OSU_APP_ENV`, `stubOsuAppEnv(overrides?)`, `stubEnv(values)` | A valid fake env and `vi.stubEnv` helpers. |
|
|
181
|
+
|
|
182
|
+
## Migration
|
|
183
|
+
|
|
184
|
+
Both apps can drop their copies for the subpaths above. Where the copies differed, this package keeps pools.haruhime.moe's behavior. What changes for packs.haruhime.moe:
|
|
185
|
+
|
|
186
|
+
- A 503 answers `unavailable`, not `internal_error`.
|
|
187
|
+
- `parseJsonBody` says "That request is too large." and "That request isn't valid." by default (pass `tooLarge` to keep "That pack is too large."), and sends a refinement's `params.code` as the error code.
|
|
188
|
+
- `?ids=` parsing is strict: `1,,2`, ` 5`, `0x10` and `1e3` are refused.
|
|
189
|
+
- `safeNextPath` sends `/signin` as `next` to the fallback.
|
|
190
|
+
- `ADMIN_OSU_IDS` is read on every call (`readIdSet`), not memoized with the server env, so a removed admin loses access at the next request.
|
|
191
|
+
- Sign-in gains account linking for osu! (a half-deleted account relinks), `onAPIError` to `/signin?error=<code>`, and an `errorCallbackURL` that keeps `next` (`osuSignIn`).
|
|
192
|
+
- better-auth's collections get their indexes. The first start after the upgrade builds them; existing duplicate users or links are logged and that index is skipped until they're merged.
|
|
193
|
+
- The cron guard is async: `await refuseWithoutBearer(...)`.
|
|
194
|
+
- `deleteRateLimitsFor(userId)` becomes `limiter.deleteSubject(rules, userId)`, which escapes the subject instead of requiring an ObjectId.
|
|
195
|
+
|
|
196
|
+
For pools.haruhime.moe, `createMongo` runs `onConnect` (the privilege check, index builds and backfill) after Mongoose is attached; `connectDb` still rejects when the check fails. Budget counters retry once on a duplicate key, as rate limits already did.
|
|
197
|
+
|
|
198
|
+
## Compatibility
|
|
199
|
+
|
|
200
|
+
ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react` and `next/navigation.js`. `server` loads `node:crypto` for machine auth.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT. See [LICENSE](LICENSE).
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
Questions and feedback are welcome on the haruhime.moe [Discord server](https://discord.gg/bKy9kjMV4y). Bugs and ideas go in [issues](https://github.com/haruhimemoe/next-kit/issues).
|
|
209
|
+
|
|
210
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) to work on the package. Changes are logged in [CHANGELOG.md](CHANGELOG.md). Report a vulnerability as [SECURITY.md](SECURITY.md) describes.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/create.ts
|
|
3
|
+
* @desc createOsuAuth: better-auth on MongoDB with osu! as the only way in. Identity only ever
|
|
4
|
+
* comes from osu! (/update-user is disabled), osu! tokens are never kept, and osu! is a
|
|
5
|
+
* trusted provider for account linking: a user row whose osu! link is gone (a deletion
|
|
6
|
+
* that stopped halfway) is relinked on the next osu! sign-in, which is safe because the
|
|
7
|
+
* only way to a user row is osu! itself. A failure with no page to return to lands on the
|
|
8
|
+
* sign-in page with ?error=<code>. The readable signed-in marker cookie follows the
|
|
9
|
+
* session: set with it, cleared on sign-out or a get-session that finds none. The app's
|
|
10
|
+
* hooks guard user, account and session creation. Moved from pools (src/lib/auth.ts);
|
|
11
|
+
* packs lacked accountLinking and onAPIError.
|
|
12
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
13
|
+
* @created Mon Sep 28, 2026
|
|
14
|
+
* @modified Mon Sep 28, 2026
|
|
15
|
+
*/
|
|
16
|
+
import { type BetterAuthOptions } from "better-auth";
|
|
17
|
+
import type { Db, MongoClient } from "mongodb";
|
|
18
|
+
import { OSU_USER_FIELDS } from "./osu.js";
|
|
19
|
+
/** Extra fields on user rows, as better-auth's user.additionalFields takes them. */
|
|
20
|
+
export type UserFields = NonNullable<NonNullable<BetterAuthOptions["user"]>["additionalFields"]>;
|
|
21
|
+
/** No extra user fields. */
|
|
22
|
+
type NoFields = Record<never, never>;
|
|
23
|
+
/** A database row as a hook sees it. */
|
|
24
|
+
export type AuthRow = Record<string, unknown>;
|
|
25
|
+
/** Guards and follow-ups on better-auth's writes. A guard returning false refuses the write. */
|
|
26
|
+
export type OsuAuthHooks = {
|
|
27
|
+
/** Before a user row is written (osu! profile already mapped). */
|
|
28
|
+
beforeUserCreate?: (user: AuthRow) => Promise<boolean | undefined>;
|
|
29
|
+
/** After a user row is written; a throw is logged and never fails the sign-in. */
|
|
30
|
+
afterUserCreate?: (user: AuthRow) => Promise<void>;
|
|
31
|
+
/** Before an osu! account link is written (tokens already dropped). */
|
|
32
|
+
beforeAccountCreate?: (account: AuthRow) => Promise<boolean | undefined>;
|
|
33
|
+
/** Before a session is written (packs: system accounts never sign in). */
|
|
34
|
+
beforeSessionCreate?: (session: AuthRow) => Promise<boolean | undefined>;
|
|
35
|
+
};
|
|
36
|
+
/** createOsuAuth's options. */
|
|
37
|
+
export type OsuAuthOptions<F extends UserFields = NoFields> = {
|
|
38
|
+
/** The osu! OAuth app. */
|
|
39
|
+
clientId: string;
|
|
40
|
+
clientSecret: string;
|
|
41
|
+
/** BETTER_AUTH_URL and BETTER_AUTH_SECRET. */
|
|
42
|
+
baseURL: string;
|
|
43
|
+
secret: string;
|
|
44
|
+
/** The app's database, and the client it's on (for the adapter). */
|
|
45
|
+
db: Db;
|
|
46
|
+
client: MongoClient;
|
|
47
|
+
/** The signed-in marker cookie's name, like "pools-signed-in". */
|
|
48
|
+
markerCookie: string;
|
|
49
|
+
/** Where errors with no page to return to land (default /signin). */
|
|
50
|
+
signInPath?: string;
|
|
51
|
+
hooks?: OsuAuthHooks;
|
|
52
|
+
/** Fields of the app's own on user rows (packs: `system`, set only by the server). */
|
|
53
|
+
userFields?: F;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* @function createOsuAuth
|
|
57
|
+
* @param options {OsuAuthOptions<F>} osu! credentials, better-auth URL and secret, the
|
|
58
|
+
* database, the marker cookie, the sign-in page, the hooks and extra user fields
|
|
59
|
+
* @returns the better-auth instance (use `typeof` it with inferAdditionalFields on the client)
|
|
60
|
+
*/
|
|
61
|
+
export declare const createOsuAuth: <F extends UserFields = NoFields>({ clientId, clientSecret, baseURL, secret, db, client, markerCookie, signInPath, hooks, userFields, }: OsuAuthOptions<F>) => import("better-auth").Auth<{
|
|
62
|
+
baseURL: string;
|
|
63
|
+
secret: string;
|
|
64
|
+
database: (options: BetterAuthOptions) => import("better-auth").DBAdapter<BetterAuthOptions>;
|
|
65
|
+
disabledPaths: string[];
|
|
66
|
+
account: {
|
|
67
|
+
accountLinking: {
|
|
68
|
+
trustedProviders: "osu"[];
|
|
69
|
+
requireLocalEmailVerified: false;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
onAPIError: {
|
|
73
|
+
errorURL: string;
|
|
74
|
+
};
|
|
75
|
+
user: {
|
|
76
|
+
additionalFields: typeof OSU_USER_FIELDS & F;
|
|
77
|
+
};
|
|
78
|
+
databaseHooks: {
|
|
79
|
+
user: {
|
|
80
|
+
create: {
|
|
81
|
+
before: (user: {
|
|
82
|
+
id: string;
|
|
83
|
+
createdAt: Date;
|
|
84
|
+
updatedAt: Date;
|
|
85
|
+
email: string;
|
|
86
|
+
emailVerified: boolean;
|
|
87
|
+
name: string;
|
|
88
|
+
image?: string | null | undefined;
|
|
89
|
+
} & Record<string, unknown>) => Promise<false | undefined>;
|
|
90
|
+
after: (user: {
|
|
91
|
+
id: string;
|
|
92
|
+
createdAt: Date;
|
|
93
|
+
updatedAt: Date;
|
|
94
|
+
email: string;
|
|
95
|
+
emailVerified: boolean;
|
|
96
|
+
name: string;
|
|
97
|
+
image?: string | null | undefined;
|
|
98
|
+
} & Record<string, unknown>) => Promise<void>;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
account: {
|
|
102
|
+
create: {
|
|
103
|
+
before: (account: {
|
|
104
|
+
id: string;
|
|
105
|
+
createdAt: Date;
|
|
106
|
+
updatedAt: Date;
|
|
107
|
+
providerId: string;
|
|
108
|
+
accountId: string;
|
|
109
|
+
userId: string;
|
|
110
|
+
accessToken?: string | null | undefined;
|
|
111
|
+
refreshToken?: string | null | undefined;
|
|
112
|
+
idToken?: string | null | undefined;
|
|
113
|
+
accessTokenExpiresAt?: Date | null | undefined;
|
|
114
|
+
refreshTokenExpiresAt?: Date | null | undefined;
|
|
115
|
+
scope?: string | null | undefined;
|
|
116
|
+
password?: string | null | undefined;
|
|
117
|
+
}) => Promise<false | {
|
|
118
|
+
data: {
|
|
119
|
+
id: string;
|
|
120
|
+
createdAt: Date;
|
|
121
|
+
updatedAt: Date;
|
|
122
|
+
providerId: string;
|
|
123
|
+
accountId: string;
|
|
124
|
+
userId: string;
|
|
125
|
+
accessToken?: string | null | undefined;
|
|
126
|
+
refreshToken?: string | null | undefined;
|
|
127
|
+
idToken?: string | null | undefined;
|
|
128
|
+
accessTokenExpiresAt?: Date | null | undefined;
|
|
129
|
+
refreshTokenExpiresAt?: Date | null | undefined;
|
|
130
|
+
scope?: string | null | undefined;
|
|
131
|
+
password?: string | null | undefined;
|
|
132
|
+
};
|
|
133
|
+
}>;
|
|
134
|
+
};
|
|
135
|
+
update: {
|
|
136
|
+
before: (account: Partial<{
|
|
137
|
+
id: string;
|
|
138
|
+
createdAt: Date;
|
|
139
|
+
updatedAt: Date;
|
|
140
|
+
providerId: string;
|
|
141
|
+
accountId: string;
|
|
142
|
+
userId: string;
|
|
143
|
+
accessToken?: string | null | undefined;
|
|
144
|
+
refreshToken?: string | null | undefined;
|
|
145
|
+
idToken?: string | null | undefined;
|
|
146
|
+
accessTokenExpiresAt?: Date | null | undefined;
|
|
147
|
+
refreshTokenExpiresAt?: Date | null | undefined;
|
|
148
|
+
scope?: string | null | undefined;
|
|
149
|
+
password?: string | null | undefined;
|
|
150
|
+
}> & Record<string, unknown>) => Promise<{
|
|
151
|
+
data: Partial<{
|
|
152
|
+
id: string;
|
|
153
|
+
createdAt: Date;
|
|
154
|
+
updatedAt: Date;
|
|
155
|
+
providerId: string;
|
|
156
|
+
accountId: string;
|
|
157
|
+
userId: string;
|
|
158
|
+
accessToken?: string | null | undefined;
|
|
159
|
+
refreshToken?: string | null | undefined;
|
|
160
|
+
idToken?: string | null | undefined;
|
|
161
|
+
accessTokenExpiresAt?: Date | null | undefined;
|
|
162
|
+
refreshTokenExpiresAt?: Date | null | undefined;
|
|
163
|
+
scope?: string | null | undefined;
|
|
164
|
+
password?: string | null | undefined;
|
|
165
|
+
}> & Record<string, unknown>;
|
|
166
|
+
}>;
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
session: {
|
|
170
|
+
create: {
|
|
171
|
+
before: (session: {
|
|
172
|
+
id: string;
|
|
173
|
+
createdAt: Date;
|
|
174
|
+
updatedAt: Date;
|
|
175
|
+
userId: string;
|
|
176
|
+
expiresAt: Date;
|
|
177
|
+
token: string;
|
|
178
|
+
ipAddress?: string | null | undefined;
|
|
179
|
+
userAgent?: string | null | undefined;
|
|
180
|
+
} & Record<string, unknown>) => Promise<false | undefined>;
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
hooks: {
|
|
185
|
+
after: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
|
|
186
|
+
};
|
|
187
|
+
plugins: [{
|
|
188
|
+
id: "generic-oauth";
|
|
189
|
+
version: string;
|
|
190
|
+
init: (ctx: import("better-auth").AuthContext) => Promise<{
|
|
191
|
+
context: {
|
|
192
|
+
socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
|
|
193
|
+
};
|
|
194
|
+
}>;
|
|
195
|
+
options: import("better-auth/plugins").GenericOAuthOptions<string>;
|
|
196
|
+
$ERROR_CODES: {
|
|
197
|
+
INVALID_OAUTH_CONFIGURATION: import("better-auth").RawError<"INVALID_OAUTH_CONFIGURATION">;
|
|
198
|
+
TOKEN_URL_NOT_FOUND: import("better-auth").RawError<"TOKEN_URL_NOT_FOUND">;
|
|
199
|
+
};
|
|
200
|
+
}];
|
|
201
|
+
}>;
|
|
202
|
+
/** The instance createOsuAuth builds (with no extra user fields). */
|
|
203
|
+
export type OsuAuth = ReturnType<typeof createOsuAuth<NoFields>>;
|
|
204
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/create.ts
|
|
3
|
+
* @desc createOsuAuth: better-auth on MongoDB with osu! as the only way in. Identity only ever
|
|
4
|
+
* comes from osu! (/update-user is disabled), osu! tokens are never kept, and osu! is a
|
|
5
|
+
* trusted provider for account linking: a user row whose osu! link is gone (a deletion
|
|
6
|
+
* that stopped halfway) is relinked on the next osu! sign-in, which is safe because the
|
|
7
|
+
* only way to a user row is osu! itself. A failure with no page to return to lands on the
|
|
8
|
+
* sign-in page with ?error=<code>. The readable signed-in marker cookie follows the
|
|
9
|
+
* session: set with it, cleared on sign-out or a get-session that finds none. The app's
|
|
10
|
+
* hooks guard user, account and session creation. Moved from pools (src/lib/auth.ts);
|
|
11
|
+
* packs lacked accountLinking and onAPIError.
|
|
12
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
13
|
+
* @created Mon Sep 28, 2026
|
|
14
|
+
* @modified Mon Sep 28, 2026
|
|
15
|
+
*/
|
|
16
|
+
import { betterAuth } from "better-auth";
|
|
17
|
+
import { mongodbAdapter } from "better-auth/adapters/mongodb";
|
|
18
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
19
|
+
import { genericOAuth } from "better-auth/plugins";
|
|
20
|
+
import { markerMaxAge } from "../auth-react/marker.js";
|
|
21
|
+
import { DEFAULT_SIGN_IN_PATH } from "../server/safe-next.js";
|
|
22
|
+
import { OSU_PROVIDER_ID, OSU_USER_FIELDS, osuProvider, withoutTokens } from "./osu.js";
|
|
23
|
+
/** Runs a guard: false refuses the write, anything else lets it through unchanged. */
|
|
24
|
+
const guard = (check) => async (row) => check && (await check(row)) === false ? false : undefined;
|
|
25
|
+
/**
|
|
26
|
+
* @function createOsuAuth
|
|
27
|
+
* @param options {OsuAuthOptions<F>} osu! credentials, better-auth URL and secret, the
|
|
28
|
+
* database, the marker cookie, the sign-in page, the hooks and extra user fields
|
|
29
|
+
* @returns the better-auth instance (use `typeof` it with inferAdditionalFields on the client)
|
|
30
|
+
*/
|
|
31
|
+
export const createOsuAuth = ({ clientId, clientSecret, baseURL, secret, db, client, markerCookie, signInPath = DEFAULT_SIGN_IN_PATH, hooks = {}, userFields, }) => {
|
|
32
|
+
const markerOptions = {
|
|
33
|
+
path: "/",
|
|
34
|
+
sameSite: "lax",
|
|
35
|
+
secure: baseURL.startsWith("https://"),
|
|
36
|
+
httpOnly: false,
|
|
37
|
+
};
|
|
38
|
+
const beforeUser = guard(hooks.beforeUserCreate);
|
|
39
|
+
const beforeAccount = guard(hooks.beforeAccountCreate);
|
|
40
|
+
const beforeSession = guard(hooks.beforeSessionCreate);
|
|
41
|
+
return betterAuth({
|
|
42
|
+
baseURL,
|
|
43
|
+
secret,
|
|
44
|
+
database: mongodbAdapter(db, { client, transaction: false }),
|
|
45
|
+
// These fields must accept input: better-auth 1.7 drops `input: false` fields from the OAuth
|
|
46
|
+
// profile too. So no client may call /update-user: identity only ever comes from osu!.
|
|
47
|
+
disabledPaths: ["/update-user"],
|
|
48
|
+
account: {
|
|
49
|
+
// `<osuId>@osu.local` belongs to whoever osu! says has that id.
|
|
50
|
+
accountLinking: { trustedProviders: [OSU_PROVIDER_ID], requireLocalEmailVerified: false },
|
|
51
|
+
},
|
|
52
|
+
onAPIError: { errorURL: new URL(signInPath, baseURL).toString() },
|
|
53
|
+
user: {
|
|
54
|
+
additionalFields: { ...OSU_USER_FIELDS, ...userFields },
|
|
55
|
+
},
|
|
56
|
+
databaseHooks: {
|
|
57
|
+
user: {
|
|
58
|
+
create: {
|
|
59
|
+
before: async (user) => beforeUser(user),
|
|
60
|
+
after: async (user) => {
|
|
61
|
+
try {
|
|
62
|
+
await hooks.afterUserCreate?.(user);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error("[auth] afterUserCreate failed", error);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
account: {
|
|
71
|
+
create: {
|
|
72
|
+
before: async (account) => (await beforeAccount(account)) === false ? false : { data: withoutTokens(account) },
|
|
73
|
+
},
|
|
74
|
+
update: { before: async (account) => ({ data: withoutTokens(account) }) },
|
|
75
|
+
},
|
|
76
|
+
session: { create: { before: async (session) => beforeSession(session) } },
|
|
77
|
+
},
|
|
78
|
+
hooks: {
|
|
79
|
+
// Keep the readable marker in step with the session, so pages without it never ask for
|
|
80
|
+
// the session at all.
|
|
81
|
+
after: createAuthMiddleware(async (ctx) => {
|
|
82
|
+
const set = (expiresAt) => ctx.setCookie(markerCookie, "1", { ...markerOptions, maxAge: markerMaxAge(expiresAt) });
|
|
83
|
+
const clear = () => ctx.setCookie(markerCookie, "", { ...markerOptions, maxAge: 0 });
|
|
84
|
+
const created = ctx.context.newSession;
|
|
85
|
+
if (created) {
|
|
86
|
+
set(created.session.expiresAt);
|
|
87
|
+
}
|
|
88
|
+
else if (ctx.path === "/sign-out") {
|
|
89
|
+
clear();
|
|
90
|
+
}
|
|
91
|
+
else if (ctx.path === "/get-session") {
|
|
92
|
+
const returned = ctx.context.returned;
|
|
93
|
+
if (returned?.session)
|
|
94
|
+
set(returned.session.expiresAt);
|
|
95
|
+
else
|
|
96
|
+
clear();
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
plugins: [genericOAuth({ config: [osuProvider({ clientId, clientSecret })] })],
|
|
101
|
+
});
|
|
102
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/index.ts
|
|
3
|
+
* @desc @haruhimemoe/next-kit/auth: better-auth with osu! sign-in. createOsuAuth, the osu!
|
|
4
|
+
* provider pieces, the indexes better-auth's collections need, and reading the caller.
|
|
5
|
+
* Server only.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
export { type AuthRow, createOsuAuth, type OsuAuth, type OsuAuthHooks, type OsuAuthOptions, type UserFields, } from "./create.js";
|
|
11
|
+
export { AUTH_INDEX_SPECS, AUTH_INDEXES } from "./indexes.js";
|
|
12
|
+
export { OSU_PROVIDER_ID, OSU_USER_FIELDS, type OsuProviderOptions, osuProfileToUser, osuProvider, withoutTokens, } from "./osu.js";
|
|
13
|
+
export { getOsuUser, type OsuSession, type OsuSessionUser, type SessionReader, toSessionUser, } from "./session.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/index.ts
|
|
3
|
+
* @desc @haruhimemoe/next-kit/auth: better-auth with osu! sign-in. createOsuAuth, the osu!
|
|
4
|
+
* provider pieces, the indexes better-auth's collections need, and reading the caller.
|
|
5
|
+
* Server only.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
export { createOsuAuth, } from "./create.js";
|
|
11
|
+
export { AUTH_INDEX_SPECS, AUTH_INDEXES } from "./indexes.js";
|
|
12
|
+
export { OSU_PROVIDER_ID, OSU_USER_FIELDS, osuProfileToUser, osuProvider, withoutTokens, } from "./osu.js";
|
|
13
|
+
export { getOsuUser, toSessionUser, } from "./session.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/indexes.ts
|
|
3
|
+
* @desc The indexes better-auth's MongoDB collections need and don't get on their own: one user
|
|
4
|
+
* per osu! id (two sign-ins racing can't make two), one account row per osu! link, one
|
|
5
|
+
* session per token (every signed-in request looks it up; a secret, so never logged), and
|
|
6
|
+
* sessions by user, plus the TTL index that deletes a session about a minute after it
|
|
7
|
+
* expires. Pass AUTH_INDEX_SPECS to ensureIndexes. Moved from pools (src/lib/db-indexes.ts,
|
|
8
|
+
* src/constants/db.ts); packs had only the session TTL, so its session lookups scanned.
|
|
9
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
10
|
+
* @created Mon Sep 28, 2026
|
|
11
|
+
* @modified Mon Sep 28, 2026
|
|
12
|
+
*/
|
|
13
|
+
import type { IndexSpec } from "../mongo/indexes.js";
|
|
14
|
+
/** Index names on better-auth's collections. */
|
|
15
|
+
export declare const AUTH_INDEXES: Readonly<{
|
|
16
|
+
userOsuId: "user_osuId_unique";
|
|
17
|
+
accountKey: "account_providerId_accountId_unique";
|
|
18
|
+
sessionToken: "session_token_unique";
|
|
19
|
+
sessionUser: "session_userId";
|
|
20
|
+
sessionTtl: "session_expiresAt_ttl";
|
|
21
|
+
}>;
|
|
22
|
+
/** The five indexes, for ensureIndexes from @haruhimemoe/next-kit/mongo. */
|
|
23
|
+
export declare const AUTH_INDEX_SPECS: readonly IndexSpec[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/indexes.ts
|
|
3
|
+
* @desc The indexes better-auth's MongoDB collections need and don't get on their own: one user
|
|
4
|
+
* per osu! id (two sign-ins racing can't make two), one account row per osu! link, one
|
|
5
|
+
* session per token (every signed-in request looks it up; a secret, so never logged), and
|
|
6
|
+
* sessions by user, plus the TTL index that deletes a session about a minute after it
|
|
7
|
+
* expires. Pass AUTH_INDEX_SPECS to ensureIndexes. Moved from pools (src/lib/db-indexes.ts,
|
|
8
|
+
* src/constants/db.ts); packs had only the session TTL, so its session lookups scanned.
|
|
9
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
10
|
+
* @created Mon Sep 28, 2026
|
|
11
|
+
* @modified Mon Sep 28, 2026
|
|
12
|
+
*/
|
|
13
|
+
/** Index names on better-auth's collections. */
|
|
14
|
+
export const AUTH_INDEXES = Object.freeze({
|
|
15
|
+
userOsuId: "user_osuId_unique",
|
|
16
|
+
accountKey: "account_providerId_accountId_unique",
|
|
17
|
+
sessionToken: "session_token_unique",
|
|
18
|
+
sessionUser: "session_userId",
|
|
19
|
+
sessionTtl: "session_expiresAt_ttl",
|
|
20
|
+
});
|
|
21
|
+
/** The five indexes, for ensureIndexes from @haruhimemoe/next-kit/mongo. */
|
|
22
|
+
export const AUTH_INDEX_SPECS = Object.freeze([
|
|
23
|
+
{ collection: "user", key: { osuId: 1 }, name: AUTH_INDEXES.userOsuId, unique: true },
|
|
24
|
+
{
|
|
25
|
+
collection: "account",
|
|
26
|
+
key: { providerId: 1, accountId: 1 },
|
|
27
|
+
name: AUTH_INDEXES.accountKey,
|
|
28
|
+
unique: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
collection: "session",
|
|
32
|
+
key: { token: 1 },
|
|
33
|
+
name: AUTH_INDEXES.sessionToken,
|
|
34
|
+
unique: true,
|
|
35
|
+
secret: true,
|
|
36
|
+
},
|
|
37
|
+
{ collection: "session", key: { userId: 1 }, name: AUTH_INDEXES.sessionUser },
|
|
38
|
+
{
|
|
39
|
+
collection: "session",
|
|
40
|
+
key: { expiresAt: 1 },
|
|
41
|
+
name: AUTH_INDEXES.sessionTtl,
|
|
42
|
+
expireAfterSeconds: 0,
|
|
43
|
+
},
|
|
44
|
+
]);
|