@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
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/osu-id.ts
|
|
3
|
+
* @desc The osu! provider id on its own, so browser code (osuSignIn) can use it without loading
|
|
4
|
+
* the profile mapping.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/** better-auth genericOAuth provider id; also the last segment of the callback path. */
|
|
10
|
+
export declare const OSU_PROVIDER_ID = "osu";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/osu-id.ts
|
|
3
|
+
* @desc The osu! provider id on its own, so browser code (osuSignIn) can use it without loading
|
|
4
|
+
* the profile mapping.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/** better-auth genericOAuth provider id; also the last segment of the callback path. */
|
|
10
|
+
export const OSU_PROVIDER_ID = "osu";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/osu.ts
|
|
3
|
+
* @desc The osu! pieces of better-auth: the provider id, the extra user fields, the profile
|
|
4
|
+
* mapping (through @haruhimemoe/osu's toOsuUser, with a synthetic email since osu! gives
|
|
5
|
+
* none), the genericOAuth provider config (identify + public, PKCE, profile refreshed on
|
|
6
|
+
* every sign-in), and dropping OAuth tokens from account writes. Moved from packs and
|
|
7
|
+
* pools (src/lib/auth.ts, src/constants/auth.ts).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { OSU_PROVIDER_ID } from "./osu-id.js";
|
|
13
|
+
export { OSU_PROVIDER_ID };
|
|
14
|
+
/** The fields osu! fills in on every user row. */
|
|
15
|
+
export declare const OSU_USER_FIELDS: {
|
|
16
|
+
readonly osuId: {
|
|
17
|
+
readonly type: "number";
|
|
18
|
+
readonly required: true;
|
|
19
|
+
};
|
|
20
|
+
readonly username: {
|
|
21
|
+
readonly type: "string";
|
|
22
|
+
readonly required: true;
|
|
23
|
+
};
|
|
24
|
+
readonly avatarUrl: {
|
|
25
|
+
readonly type: "string";
|
|
26
|
+
readonly required: false;
|
|
27
|
+
};
|
|
28
|
+
readonly countryCode: {
|
|
29
|
+
readonly type: "string";
|
|
30
|
+
readonly required: false;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* @function osuProfileToUser
|
|
35
|
+
* @param raw {unknown} the /api/v2/me profile better-auth fetched
|
|
36
|
+
* @returns the better-auth user fields. osu! OAuth returns no email, so each osu! id gets a stable
|
|
37
|
+
* synthetic one (the adapter requires an email). better-auth wants image left out, not
|
|
38
|
+
* null, when there is no avatar.
|
|
39
|
+
* @throws {z.ZodError} when the profile has no id or username (never guess an identity)
|
|
40
|
+
*/
|
|
41
|
+
export declare const osuProfileToUser: (raw: unknown) => {
|
|
42
|
+
osuId: number;
|
|
43
|
+
username: string;
|
|
44
|
+
avatarUrl: string | null;
|
|
45
|
+
countryCode: string | null;
|
|
46
|
+
email: string;
|
|
47
|
+
emailVerified: false;
|
|
48
|
+
name: string;
|
|
49
|
+
image?: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* @function withoutTokens
|
|
53
|
+
* @param account {T} an account write
|
|
54
|
+
* @returns {T} the same fields with accessToken, refreshToken and idToken null: neither app calls
|
|
55
|
+
* osu! as the user, so none keeps osu! tokens
|
|
56
|
+
*/
|
|
57
|
+
export declare const withoutTokens: <T extends Record<string, unknown>>(account: T) => T;
|
|
58
|
+
/** osuProvider's input: the osu! OAuth app's credentials. */
|
|
59
|
+
export type OsuProviderOptions = {
|
|
60
|
+
clientId: string;
|
|
61
|
+
clientSecret: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* @function osuProvider
|
|
65
|
+
* @param options {OsuProviderOptions} the osu! app's client id and secret
|
|
66
|
+
* @returns the genericOAuth config entry for osu!: identify + public scopes, PKCE, the profile
|
|
67
|
+
* mapped with osuProfileToUser and refreshed on every sign-in
|
|
68
|
+
*/
|
|
69
|
+
export declare const osuProvider: ({ clientId, clientSecret }: OsuProviderOptions) => {
|
|
70
|
+
authorizationUrl: "https://osu.ppy.sh/oauth/authorize";
|
|
71
|
+
tokenUrl: "https://osu.ppy.sh/oauth/token";
|
|
72
|
+
userInfoUrl: "https://osu.ppy.sh/api/v2/me";
|
|
73
|
+
providerId: string;
|
|
74
|
+
clientId: string;
|
|
75
|
+
clientSecret: string;
|
|
76
|
+
scopes: ("identify" | "public")[];
|
|
77
|
+
pkce: boolean;
|
|
78
|
+
overrideUserInfo: boolean;
|
|
79
|
+
mapProfileToUser: typeof osuProfileToUser;
|
|
80
|
+
};
|
package/dist/auth/osu.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/osu.ts
|
|
3
|
+
* @desc The osu! pieces of better-auth: the provider id, the extra user fields, the profile
|
|
4
|
+
* mapping (through @haruhimemoe/osu's toOsuUser, with a synthetic email since osu! gives
|
|
5
|
+
* none), the genericOAuth provider config (identify + public, PKCE, profile refreshed on
|
|
6
|
+
* every sign-in), and dropping OAuth tokens from account writes. Moved from packs and
|
|
7
|
+
* pools (src/lib/auth.ts, src/constants/auth.ts).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { OSU_OAUTH, OSU_SIGN_IN_SCOPES, toOsuUser } from "@haruhimemoe/osu/shapes";
|
|
13
|
+
import { OSU_PROVIDER_ID } from "./osu-id.js";
|
|
14
|
+
export { OSU_PROVIDER_ID };
|
|
15
|
+
/** The fields osu! fills in on every user row. */
|
|
16
|
+
export const OSU_USER_FIELDS = {
|
|
17
|
+
osuId: { type: "number", required: true },
|
|
18
|
+
username: { type: "string", required: true },
|
|
19
|
+
avatarUrl: { type: "string", required: false },
|
|
20
|
+
countryCode: { type: "string", required: false },
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* @function osuProfileToUser
|
|
24
|
+
* @param raw {unknown} the /api/v2/me profile better-auth fetched
|
|
25
|
+
* @returns the better-auth user fields. osu! OAuth returns no email, so each osu! id gets a stable
|
|
26
|
+
* synthetic one (the adapter requires an email). better-auth wants image left out, not
|
|
27
|
+
* null, when there is no avatar.
|
|
28
|
+
* @throws {z.ZodError} when the profile has no id or username (never guess an identity)
|
|
29
|
+
*/
|
|
30
|
+
export const osuProfileToUser = (raw) => {
|
|
31
|
+
const user = toOsuUser(raw);
|
|
32
|
+
return {
|
|
33
|
+
email: `${user.osuId}@osu.local`,
|
|
34
|
+
emailVerified: false,
|
|
35
|
+
name: user.username,
|
|
36
|
+
...user,
|
|
37
|
+
...(user.avatarUrl ? { image: user.avatarUrl } : {}),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* @function withoutTokens
|
|
42
|
+
* @param account {T} an account write
|
|
43
|
+
* @returns {T} the same fields with accessToken, refreshToken and idToken null: neither app calls
|
|
44
|
+
* osu! as the user, so none keeps osu! tokens
|
|
45
|
+
*/
|
|
46
|
+
export const withoutTokens = (account) => ({
|
|
47
|
+
...account,
|
|
48
|
+
accessToken: null,
|
|
49
|
+
refreshToken: null,
|
|
50
|
+
idToken: null,
|
|
51
|
+
});
|
|
52
|
+
/**
|
|
53
|
+
* @function osuProvider
|
|
54
|
+
* @param options {OsuProviderOptions} the osu! app's client id and secret
|
|
55
|
+
* @returns the genericOAuth config entry for osu!: identify + public scopes, PKCE, the profile
|
|
56
|
+
* mapped with osuProfileToUser and refreshed on every sign-in
|
|
57
|
+
*/
|
|
58
|
+
export const osuProvider = ({ clientId, clientSecret }) => ({
|
|
59
|
+
providerId: OSU_PROVIDER_ID,
|
|
60
|
+
clientId,
|
|
61
|
+
clientSecret,
|
|
62
|
+
...OSU_OAUTH,
|
|
63
|
+
scopes: [...OSU_SIGN_IN_SCOPES],
|
|
64
|
+
pkce: true,
|
|
65
|
+
overrideUserInfo: true,
|
|
66
|
+
mapProfileToUser: osuProfileToUser,
|
|
67
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/session.ts
|
|
3
|
+
* @desc Reading the caller from a request's session cookie. Route handlers pass
|
|
4
|
+
* request.headers; server pages pass await headers(). Apps add their own rules on top
|
|
5
|
+
* (an admin list, refusing system accounts). Moved from packs and pools
|
|
6
|
+
* (getUserFromHeaders in src/lib/auth.ts).
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
/** The signed-in osu! user, as both apps pass it around. */
|
|
12
|
+
export type OsuSessionUser = {
|
|
13
|
+
id: string;
|
|
14
|
+
osuId: number;
|
|
15
|
+
username: string;
|
|
16
|
+
avatarUrl: string | null;
|
|
17
|
+
};
|
|
18
|
+
/** A session as better-auth returns it with the osu! user fields. */
|
|
19
|
+
export type OsuSession = {
|
|
20
|
+
user: {
|
|
21
|
+
id: string;
|
|
22
|
+
osuId: number;
|
|
23
|
+
username: string;
|
|
24
|
+
avatarUrl?: string | null | undefined;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/** What getOsuUser needs from a better-auth instance. */
|
|
28
|
+
export type SessionReader = {
|
|
29
|
+
api: {
|
|
30
|
+
getSession: (input: {
|
|
31
|
+
headers: Headers;
|
|
32
|
+
}) => Promise<OsuSession | null>;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* @function toSessionUser
|
|
37
|
+
* @param session {OsuSession} a session better-auth returned
|
|
38
|
+
* @returns {OsuSessionUser} id, osu! id, username and avatar (null when there is none)
|
|
39
|
+
*/
|
|
40
|
+
export declare const toSessionUser: ({ user }: OsuSession) => OsuSessionUser;
|
|
41
|
+
/**
|
|
42
|
+
* @function getOsuUser
|
|
43
|
+
* @param auth {SessionReader} the better-auth instance (createOsuAuth's)
|
|
44
|
+
* @param headers {Headers} request headers (the session cookie)
|
|
45
|
+
* @returns {Promise<OsuSessionUser | null>} the signed-in user, or null (no, forged or expired
|
|
46
|
+
* session)
|
|
47
|
+
*/
|
|
48
|
+
export declare const getOsuUser: (auth: SessionReader, headers: Headers) => Promise<OsuSessionUser | null>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth/session.ts
|
|
3
|
+
* @desc Reading the caller from a request's session cookie. Route handlers pass
|
|
4
|
+
* request.headers; server pages pass await headers(). Apps add their own rules on top
|
|
5
|
+
* (an admin list, refusing system accounts). Moved from packs and pools
|
|
6
|
+
* (getUserFromHeaders in src/lib/auth.ts).
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* @function toSessionUser
|
|
13
|
+
* @param session {OsuSession} a session better-auth returned
|
|
14
|
+
* @returns {OsuSessionUser} id, osu! id, username and avatar (null when there is none)
|
|
15
|
+
*/
|
|
16
|
+
export const toSessionUser = ({ user }) => ({
|
|
17
|
+
id: user.id,
|
|
18
|
+
osuId: user.osuId,
|
|
19
|
+
username: user.username,
|
|
20
|
+
avatarUrl: user.avatarUrl ?? null,
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* @function getOsuUser
|
|
24
|
+
* @param auth {SessionReader} the better-auth instance (createOsuAuth's)
|
|
25
|
+
* @param headers {Headers} request headers (the session cookie)
|
|
26
|
+
* @returns {Promise<OsuSessionUser | null>} the signed-in user, or null (no, forged or expired
|
|
27
|
+
* session)
|
|
28
|
+
*/
|
|
29
|
+
export const getOsuUser = async (auth, headers) => {
|
|
30
|
+
const session = await auth.api.getSession({ headers });
|
|
31
|
+
return session ? toSessionUser(session) : null;
|
|
32
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/RestoreSignedIn.tsx
|
|
3
|
+
* @desc For pages that know on the server that the visitor is signed in (the sign-in page with a
|
|
4
|
+
* session, an account page): if this browser has no signed-in marker (a session from before
|
|
5
|
+
* the marker existed, or a cleared cookie), ask for the session once, which sets the marker
|
|
6
|
+
* and fixes the header. With `next`, the sign-in page then continues there. Moved from packs
|
|
7
|
+
* and pools (src/components/auth/RestoreSignedIn.tsx, body-identical).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { type ReactNode } from "react";
|
|
13
|
+
/** RestoreSignedIn's props. */
|
|
14
|
+
export type RestoreSignedInProps = {
|
|
15
|
+
/** The page-wide account store (createAccount's `store`). */
|
|
16
|
+
store: {
|
|
17
|
+
recheck: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/** The app's marker check (createSignedInMarker's `has`). */
|
|
20
|
+
hasMarker: (cookieHeader: string) => boolean;
|
|
21
|
+
/** Where to go once the session is restored (the sign-in page's `next`). */
|
|
22
|
+
next?: string;
|
|
23
|
+
/** Shown while it goes on to `next`. */
|
|
24
|
+
pending?: ReactNode;
|
|
25
|
+
/** Test seam (default: document.cookie). */
|
|
26
|
+
readCookie?: () => string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* @function RestoreSignedIn
|
|
30
|
+
* @param props {RestoreSignedInProps} the store, the marker check, `next` and what to show
|
|
31
|
+
* @returns {ReactNode} nothing without `next`; `pending` while it goes on to `next`
|
|
32
|
+
*/
|
|
33
|
+
export declare function RestoreSignedIn({ store, hasMarker, next, pending, readCookie, }: RestoreSignedInProps): ReactNode;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/RestoreSignedIn.tsx
|
|
3
|
+
* @desc For pages that know on the server that the visitor is signed in (the sign-in page with a
|
|
4
|
+
* session, an account page): if this browser has no signed-in marker (a session from before
|
|
5
|
+
* the marker existed, or a cleared cookie), ask for the session once, which sets the marker
|
|
6
|
+
* and fixes the header. With `next`, the sign-in page then continues there. Moved from packs
|
|
7
|
+
* and pools (src/components/auth/RestoreSignedIn.tsx, body-identical).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
"use client";
|
|
13
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
14
|
+
import { useRouter } from "next/navigation.js";
|
|
15
|
+
import { useEffect } from "react";
|
|
16
|
+
const readDocumentCookie = () => document.cookie;
|
|
17
|
+
const SIGNING_IN = _jsx("p", { className: "text-c3 text-sm", children: "Signing you in\u2026" });
|
|
18
|
+
/**
|
|
19
|
+
* @function RestoreSignedIn
|
|
20
|
+
* @param props {RestoreSignedInProps} the store, the marker check, `next` and what to show
|
|
21
|
+
* @returns {ReactNode} nothing without `next`; `pending` while it goes on to `next`
|
|
22
|
+
*/
|
|
23
|
+
export function RestoreSignedIn({ store, hasMarker, next, pending = SIGNING_IN, readCookie = readDocumentCookie, }) {
|
|
24
|
+
const router = useRouter();
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (next === undefined && hasMarker(readCookie()))
|
|
27
|
+
return;
|
|
28
|
+
let live = true;
|
|
29
|
+
store.recheck().then(() => {
|
|
30
|
+
if (live && next !== undefined)
|
|
31
|
+
router.replace(next);
|
|
32
|
+
});
|
|
33
|
+
return () => {
|
|
34
|
+
live = false;
|
|
35
|
+
};
|
|
36
|
+
}, [next, store, hasMarker, readCookie, router]);
|
|
37
|
+
return next === undefined ? null : pending;
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/account-store.ts
|
|
3
|
+
* @desc Who is signed in, for a header's account menu. The store asks the server only when the
|
|
4
|
+
* readable signed-in marker is present, once per page load, so anonymous visitors cost no
|
|
5
|
+
* request; another tab signing in or out is caught up when this one is shown again, at a
|
|
6
|
+
* request only when the marker changed. markSignedOut() updates every subscriber at once.
|
|
7
|
+
* Moved from packs and pools (src/hooks/useAccount.ts, body-identical).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** The account as the page sees it. */
|
|
13
|
+
export type Account = {
|
|
14
|
+
status: "loading";
|
|
15
|
+
} | {
|
|
16
|
+
status: "signed-out";
|
|
17
|
+
} | {
|
|
18
|
+
status: "signed-in";
|
|
19
|
+
user: {
|
|
20
|
+
id: string;
|
|
21
|
+
username: string;
|
|
22
|
+
avatarUrl: string | null;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** The part of a better-auth session the store reads. */
|
|
26
|
+
export type SessionData = {
|
|
27
|
+
user: {
|
|
28
|
+
id: string;
|
|
29
|
+
username: string;
|
|
30
|
+
avatarUrl?: string | null;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** What the store needs: a session fetch, a cookie read and a marker clear (tests pass fakes). */
|
|
34
|
+
export type AccountDeps = {
|
|
35
|
+
getSession: () => Promise<SessionData | null>;
|
|
36
|
+
readCookie: () => string;
|
|
37
|
+
hasMarker: (cookieHeader: string) => boolean;
|
|
38
|
+
clearMarker: () => void;
|
|
39
|
+
};
|
|
40
|
+
/** A useSyncExternalStore store of the account. */
|
|
41
|
+
export type AccountStore = {
|
|
42
|
+
subscribe: (listener: () => void) => () => void;
|
|
43
|
+
getSnapshot: () => Account;
|
|
44
|
+
markSignedOut: () => void;
|
|
45
|
+
/** Ask the server even without the marker (pages that know the user is signed in). */
|
|
46
|
+
recheck: () => Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
/** Before the first answer, and during server render. */
|
|
49
|
+
export declare const LOADING: Account;
|
|
50
|
+
/**
|
|
51
|
+
* @function createAccountStore
|
|
52
|
+
* @param deps {AccountDeps} session fetch, cookie read, marker check and clear
|
|
53
|
+
* @returns {AccountStore} starts on the first subscription
|
|
54
|
+
*/
|
|
55
|
+
export declare const createAccountStore: ({ getSession, readCookie, hasMarker, clearMarker, }: AccountDeps) => AccountStore;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/account-store.ts
|
|
3
|
+
* @desc Who is signed in, for a header's account menu. The store asks the server only when the
|
|
4
|
+
* readable signed-in marker is present, once per page load, so anonymous visitors cost no
|
|
5
|
+
* request; another tab signing in or out is caught up when this one is shown again, at a
|
|
6
|
+
* request only when the marker changed. markSignedOut() updates every subscriber at once.
|
|
7
|
+
* Moved from packs and pools (src/hooks/useAccount.ts, body-identical).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** Before the first answer, and during server render. */
|
|
13
|
+
export const LOADING = Object.freeze({ status: "loading" });
|
|
14
|
+
const SIGNED_OUT = Object.freeze({ status: "signed-out" });
|
|
15
|
+
/**
|
|
16
|
+
* @function createAccountStore
|
|
17
|
+
* @param deps {AccountDeps} session fetch, cookie read, marker check and clear
|
|
18
|
+
* @returns {AccountStore} starts on the first subscription
|
|
19
|
+
*/
|
|
20
|
+
export const createAccountStore = ({ getSession, readCookie, hasMarker, clearMarker, }) => {
|
|
21
|
+
let state = LOADING;
|
|
22
|
+
let started = false;
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
const set = (next) => {
|
|
25
|
+
state = next;
|
|
26
|
+
for (const listener of listeners)
|
|
27
|
+
listener();
|
|
28
|
+
};
|
|
29
|
+
// Asks the server; a session there also (re)sets the marker cookie.
|
|
30
|
+
const load = () => getSession().then((data) => {
|
|
31
|
+
if (!data) {
|
|
32
|
+
clearMarker();
|
|
33
|
+
set(SIGNED_OUT);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const { id, username, avatarUrl } = data.user;
|
|
37
|
+
set({ status: "signed-in", user: { id, username, avatarUrl: avatarUrl ?? null } });
|
|
38
|
+
}, () => set(SIGNED_OUT));
|
|
39
|
+
const sync = () => {
|
|
40
|
+
if (document.visibilityState === "hidden")
|
|
41
|
+
return;
|
|
42
|
+
const marked = hasMarker(readCookie());
|
|
43
|
+
if (!marked && state.status === "signed-in")
|
|
44
|
+
set(SIGNED_OUT);
|
|
45
|
+
else if (marked && state.status === "signed-out")
|
|
46
|
+
void load();
|
|
47
|
+
};
|
|
48
|
+
const start = () => {
|
|
49
|
+
if (started)
|
|
50
|
+
return;
|
|
51
|
+
started = true;
|
|
52
|
+
if (typeof document !== "undefined")
|
|
53
|
+
document.addEventListener("visibilitychange", sync);
|
|
54
|
+
if (!hasMarker(readCookie())) {
|
|
55
|
+
set(SIGNED_OUT);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
void load();
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
subscribe: (listener) => {
|
|
62
|
+
listeners.add(listener);
|
|
63
|
+
start();
|
|
64
|
+
return () => {
|
|
65
|
+
listeners.delete(listener);
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
getSnapshot: () => state,
|
|
69
|
+
markSignedOut: () => {
|
|
70
|
+
clearMarker();
|
|
71
|
+
set(SIGNED_OUT);
|
|
72
|
+
},
|
|
73
|
+
recheck: () => {
|
|
74
|
+
started = true;
|
|
75
|
+
return load();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/index.ts
|
|
3
|
+
* @desc @haruhimemoe/next-kit/auth-react: the browser side of osu! sign-in. The signed-in marker
|
|
4
|
+
* cookie, the account store and useAccount, RestoreSignedIn, what the sign-in button
|
|
5
|
+
* sends, and where to go after sign-in. No "use client" here: the hook and component
|
|
6
|
+
* files carry it, so a server page can still call safeNextPath from this entry point.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
export { OSU_PROVIDER_ID } from "../auth/osu-id.js";
|
|
12
|
+
export { DEFAULT_SIGN_IN_PATH, type SafeNextOptions, safeNextPath, signInHref, } from "../server/safe-next.js";
|
|
13
|
+
export { type Account, type AccountDeps, type AccountStore, createAccountStore, LOADING, type SessionData, } from "./account-store.js";
|
|
14
|
+
export { createSignedInMarker, markerMaxAge, type SignedInMarker } from "./marker.js";
|
|
15
|
+
export { RestoreSignedIn, type RestoreSignedInProps } from "./RestoreSignedIn.js";
|
|
16
|
+
export { type OsuSignIn, osuSignIn } from "./sign-in.js";
|
|
17
|
+
export { type AccountKit, type BoundRestoreProps, createAccount, type SessionClient, sessionFetcher, useAccount, } from "./use-account.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/index.ts
|
|
3
|
+
* @desc @haruhimemoe/next-kit/auth-react: the browser side of osu! sign-in. The signed-in marker
|
|
4
|
+
* cookie, the account store and useAccount, RestoreSignedIn, what the sign-in button
|
|
5
|
+
* sends, and where to go after sign-in. No "use client" here: the hook and component
|
|
6
|
+
* files carry it, so a server page can still call safeNextPath from this entry point.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
export { OSU_PROVIDER_ID } from "../auth/osu-id.js";
|
|
12
|
+
export { DEFAULT_SIGN_IN_PATH, safeNextPath, signInHref, } from "../server/safe-next.js";
|
|
13
|
+
export { createAccountStore, LOADING, } from "./account-store.js";
|
|
14
|
+
export { createSignedInMarker, markerMaxAge } from "./marker.js";
|
|
15
|
+
export { RestoreSignedIn } from "./RestoreSignedIn.js";
|
|
16
|
+
export { osuSignIn } from "./sign-in.js";
|
|
17
|
+
export { createAccount, sessionFetcher, useAccount, } from "./use-account.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/marker.ts
|
|
3
|
+
* @desc A readable "this browser may be signed in" cookie. It holds no secret; it only tells the
|
|
4
|
+
* page whether asking the server for the session is worth a request, so visitors who never
|
|
5
|
+
* sign in cost none. The server sets it with the session and clears it on sign-out or a
|
|
6
|
+
* missing session (createOsuAuth in @haruhimemoe/next-kit/auth). Browser-safe. Moved from
|
|
7
|
+
* packs and pools (src/lib/signed-in-marker.ts), which differed only in the cookie name.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** The marker for one cookie name. */
|
|
13
|
+
export type SignedInMarker = {
|
|
14
|
+
/** The cookie's name, like "pools-signed-in". */
|
|
15
|
+
name: string;
|
|
16
|
+
/** True when a Cookie header (or document.cookie) has the marker set. */
|
|
17
|
+
has: (cookieHeader: string) => boolean;
|
|
18
|
+
/** Expires the marker on a document (tests pass a stand-in). */
|
|
19
|
+
clear: (target?: {
|
|
20
|
+
cookie: string;
|
|
21
|
+
}) => void;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* @function markerMaxAge
|
|
25
|
+
* @param expiresAt {Date | string} when the session expires
|
|
26
|
+
* @param now {number} current time in ms (tests)
|
|
27
|
+
* @returns {number} whole seconds until then, at least 0
|
|
28
|
+
*/
|
|
29
|
+
export declare const markerMaxAge: (expiresAt: Date | string, now?: number) => number;
|
|
30
|
+
/**
|
|
31
|
+
* @function createSignedInMarker
|
|
32
|
+
* @param name {string} the cookie's name, one per app
|
|
33
|
+
* @returns {SignedInMarker} has and clear for that cookie
|
|
34
|
+
*/
|
|
35
|
+
export declare const createSignedInMarker: (name: string) => SignedInMarker;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/marker.ts
|
|
3
|
+
* @desc A readable "this browser may be signed in" cookie. It holds no secret; it only tells the
|
|
4
|
+
* page whether asking the server for the session is worth a request, so visitors who never
|
|
5
|
+
* sign in cost none. The server sets it with the session and clears it on sign-out or a
|
|
6
|
+
* missing session (createOsuAuth in @haruhimemoe/next-kit/auth). Browser-safe. Moved from
|
|
7
|
+
* packs and pools (src/lib/signed-in-marker.ts), which differed only in the cookie name.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* @function markerMaxAge
|
|
14
|
+
* @param expiresAt {Date | string} when the session expires
|
|
15
|
+
* @param now {number} current time in ms (tests)
|
|
16
|
+
* @returns {number} whole seconds until then, at least 0
|
|
17
|
+
*/
|
|
18
|
+
export const markerMaxAge = (expiresAt, now = Date.now()) => Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000));
|
|
19
|
+
/**
|
|
20
|
+
* @function createSignedInMarker
|
|
21
|
+
* @param name {string} the cookie's name, one per app
|
|
22
|
+
* @returns {SignedInMarker} has and clear for that cookie
|
|
23
|
+
*/
|
|
24
|
+
export const createSignedInMarker = (name) => ({
|
|
25
|
+
name,
|
|
26
|
+
has: (cookieHeader) => cookieHeader.split(/;\s*/).includes(`${name}=1`),
|
|
27
|
+
clear: (target = document) => {
|
|
28
|
+
target.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/sign-in.ts
|
|
3
|
+
* @desc What a "Sign in with osu!" button sends better-auth: the provider, where to land, and an
|
|
4
|
+
* error page that keeps `next`, so a failed sign-in explains itself and can still go on
|
|
5
|
+
* (better-auth adds &error=<code>). pools did this; packs sent /signin?error=oauth and
|
|
6
|
+
* lost `next`.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
/** The body of authClient.signIn.social for osu!. */
|
|
12
|
+
export type OsuSignIn = {
|
|
13
|
+
provider: string;
|
|
14
|
+
callbackURL: string;
|
|
15
|
+
errorCallbackURL: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function osuSignIn
|
|
19
|
+
* @param next {string} where to land after signing in (already checked with safeNextPath)
|
|
20
|
+
* @param signInPath {string} the sign-in page (default /signin)
|
|
21
|
+
* @returns {OsuSignIn} { provider: "osu", callbackURL: next, errorCallbackURL:
|
|
22
|
+
* "/signin?next=<next>" }
|
|
23
|
+
*/
|
|
24
|
+
export declare const osuSignIn: (next: string, signInPath?: string) => OsuSignIn;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/sign-in.ts
|
|
3
|
+
* @desc What a "Sign in with osu!" button sends better-auth: the provider, where to land, and an
|
|
4
|
+
* error page that keeps `next`, so a failed sign-in explains itself and can still go on
|
|
5
|
+
* (better-auth adds &error=<code>). pools did this; packs sent /signin?error=oauth and
|
|
6
|
+
* lost `next`.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import { OSU_PROVIDER_ID } from "../auth/osu-id.js";
|
|
12
|
+
import { DEFAULT_SIGN_IN_PATH, signInHref } from "../server/safe-next.js";
|
|
13
|
+
/**
|
|
14
|
+
* @function osuSignIn
|
|
15
|
+
* @param next {string} where to land after signing in (already checked with safeNextPath)
|
|
16
|
+
* @param signInPath {string} the sign-in page (default /signin)
|
|
17
|
+
* @returns {OsuSignIn} { provider: "osu", callbackURL: next, errorCallbackURL:
|
|
18
|
+
* "/signin?next=<next>" }
|
|
19
|
+
*/
|
|
20
|
+
export const osuSignIn = (next, signInPath = DEFAULT_SIGN_IN_PATH) => ({
|
|
21
|
+
provider: OSU_PROVIDER_ID,
|
|
22
|
+
callbackURL: next,
|
|
23
|
+
errorCallbackURL: signInHref(next, signInPath),
|
|
24
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/use-account.ts
|
|
3
|
+
* @desc The React side of the account store: useAccount (loading during server render), and
|
|
4
|
+
* createAccount, which wires a store to a better-auth client and the app's marker cookie
|
|
5
|
+
* the way packs and pools each did at the bottom of src/hooks/useAccount.ts.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactNode } from "react";
|
|
11
|
+
import { type Account, type AccountStore, type SessionData } from "./account-store.js";
|
|
12
|
+
import type { SignedInMarker } from "./marker.js";
|
|
13
|
+
/** The part of a better-auth client the store calls. */
|
|
14
|
+
export type SessionClient = {
|
|
15
|
+
getSession: () => Promise<{
|
|
16
|
+
data?: unknown;
|
|
17
|
+
error?: unknown;
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* @function sessionFetcher
|
|
22
|
+
* @param client {SessionClient} the app's better-auth client
|
|
23
|
+
* @returns {() => Promise<SessionData | null>} a getSession that throws better-auth's error
|
|
24
|
+
*/
|
|
25
|
+
export declare const sessionFetcher: (client: SessionClient) => () => Promise<SessionData | null>;
|
|
26
|
+
/**
|
|
27
|
+
* @function useAccount
|
|
28
|
+
* @param store {AccountStore} the page-wide store
|
|
29
|
+
* @returns {Account} current account state ("loading" during server render)
|
|
30
|
+
*/
|
|
31
|
+
export declare const useAccount: (store: AccountStore) => Account;
|
|
32
|
+
/** RestoreSignedIn with the store and marker already bound: what a server page can render. */
|
|
33
|
+
export type BoundRestoreProps = {
|
|
34
|
+
next?: string;
|
|
35
|
+
pending?: ReactNode;
|
|
36
|
+
};
|
|
37
|
+
/** What createAccount returns: the store, and the hook, sign-out and component bound to it. */
|
|
38
|
+
export type AccountKit = {
|
|
39
|
+
store: AccountStore;
|
|
40
|
+
useAccount: () => Account;
|
|
41
|
+
markSignedOut: () => void;
|
|
42
|
+
RestoreSignedIn: (props: BoundRestoreProps) => ReactNode;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* @function createAccount
|
|
46
|
+
* @param client {SessionClient} the app's better-auth client
|
|
47
|
+
* @param marker {SignedInMarker} the app's signed-in marker
|
|
48
|
+
* @returns {AccountKit} a page-wide store reading document.cookie, with its hook, sign-out and
|
|
49
|
+
* RestoreSignedIn (export them from the app's own "use client" module, so server pages
|
|
50
|
+
* render <RestoreSignedIn next={next} /> with only serializable props)
|
|
51
|
+
*/
|
|
52
|
+
export declare const createAccount: (client: SessionClient, marker: SignedInMarker) => AccountKit;
|