@cosmicdrift/kumiko-bundled-features 0.87.0 → 0.87.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.87.0",
3
+ "version": "0.87.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -86,11 +86,11 @@
86
86
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
87
87
  },
88
88
  "dependencies": {
89
- "@cosmicdrift/kumiko-dispatcher-live": "0.87.0",
90
- "@cosmicdrift/kumiko-framework": "0.87.0",
91
- "@cosmicdrift/kumiko-headless": "0.87.0",
92
- "@cosmicdrift/kumiko-renderer": "0.87.0",
93
- "@cosmicdrift/kumiko-renderer-web": "0.87.0",
89
+ "@cosmicdrift/kumiko-dispatcher-live": "0.87.1",
90
+ "@cosmicdrift/kumiko-framework": "0.87.1",
91
+ "@cosmicdrift/kumiko-headless": "0.87.1",
92
+ "@cosmicdrift/kumiko-renderer": "0.87.1",
93
+ "@cosmicdrift/kumiko-renderer-web": "0.87.1",
94
94
  "@mollie/api-client": "^4.5.0",
95
95
  "@node-rs/argon2": "^2.0.2",
96
96
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { emailPasswordClient } from "../client-plugin";
3
+ import { hasLikelyAuthSession } from "../session";
4
+
5
+ describe("hasLikelyAuthSession", () => {
6
+ test("no kumiko_csrf cookie → false", () => {
7
+ expect(hasLikelyAuthSession("theme=dark")).toBe(false);
8
+ });
9
+
10
+ test("kumiko_csrf present → true", () => {
11
+ expect(hasLikelyAuthSession("kumiko_csrf=abc-123")).toBe(true);
12
+ });
13
+ });
14
+
15
+ describe("emailPasswordClient", () => {
16
+ test("registers SessionAuthGate as gate, not SessionProvider as provider", () => {
17
+ const feature = emailPasswordClient();
18
+ expect(feature.providers).toEqual([]);
19
+ expect(feature.gates).toHaveLength(1);
20
+ expect(feature.gates[0]?.name).toBe("SessionAuthGate");
21
+ });
22
+ });
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { ComponentType, ReactNode } from "react";
13
13
  import { LoginScreen, type LoginScreenProps } from "./login-screen";
14
- import { useSession } from "./session";
14
+ import { SessionProvider, useSession } from "./session";
15
15
 
16
16
  export function makeAuthGate(
17
17
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
@@ -31,3 +31,22 @@ export function makeAuthGate(
31
31
  }
32
32
  return AuthGate;
33
33
  }
34
+
35
+ /** SessionProvider + AuthGate als ein Gate — damit öffentliche Gates davor
36
+ * (z.B. /rechner) den Session-Bootstrap nicht mounten. createKumikoApp
37
+ * stackt providers außerhalb aller gates; SessionProvider darf deshalb
38
+ * kein provider mehr sein. */
39
+ export function makeSessionAuthGate(
40
+ LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
41
+ loginProps?: LoginScreenProps,
42
+ ): ComponentType<{ children: ReactNode }> {
43
+ const AuthGate = makeAuthGate(LoginComponent, loginProps);
44
+ function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
45
+ return (
46
+ <SessionProvider>
47
+ <AuthGate>{children}</AuthGate>
48
+ </SessionProvider>
49
+ );
50
+ }
51
+ return SessionAuthGate;
52
+ }
@@ -8,9 +8,8 @@
8
8
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
9
  import type { ComponentType, ReactNode } from "react";
10
10
  import { defaultTranslations, mergeTranslations } from "../i18n";
11
- import { makeAuthGate } from "./auth-gate";
11
+ import { makeSessionAuthGate } from "./auth-gate";
12
12
  import type { LoginScreenProps } from "./login-screen";
13
- import { SessionProvider } from "./session";
14
13
 
15
14
  export type EmailPasswordClientOptions = {
16
15
  /** Eigener Login-Screen. Default: der shadcn-stylte LoginScreen
@@ -41,8 +40,8 @@ export function emailPasswordClient(
41
40
  const translations = mergeTranslations(defaultTranslations, options.translations ?? {});
42
41
  return {
43
42
  name: "auth-email-password",
44
- providers: [SessionProvider],
45
- gates: [makeAuthGate(options.loginScreen, options.loginScreenProps)],
43
+ providers: [],
44
+ gates: [makeSessionAuthGate(options.loginScreen, options.loginScreenProps)],
46
45
  translations,
47
46
  };
48
47
  }
@@ -26,7 +26,7 @@ export {
26
26
  } from "./auth-client";
27
27
  export type { AuthShellRenderer } from "./auth-form-primitives";
28
28
  export { AuthShellProvider, useAuthShell } from "./auth-form-primitives";
29
- export { makeAuthGate } from "./auth-gate";
29
+ export { makeAuthGate, makeSessionAuthGate } from "./auth-gate";
30
30
  export type {
31
31
  EmailPasswordClientFeature,
32
32
  EmailPasswordClientOptions,
@@ -43,7 +43,7 @@ export { LoginScreen } from "./login-screen";
43
43
  export type { ResetPasswordScreenProps } from "./reset-password-screen";
44
44
  export { ResetPasswordScreen } from "./reset-password-screen";
45
45
  export type { SessionApi, SessionState, SessionStatus } from "./session";
46
- export { SessionContext, SessionProvider, useSession } from "./session";
46
+ export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session";
47
47
  export type { SignupCompleteScreenProps } from "./signup-complete-screen";
48
48
  export { SignupCompleteScreen } from "./signup-complete-screen";
49
49
  export type { SignupScreenProps } from "./signup-screen";
@@ -9,6 +9,7 @@
9
9
  // unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
10
10
  // und die Transitions.
11
11
 
12
+ import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
12
13
  import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
13
14
  import {
14
15
  type CurrentUserProfile,
@@ -43,6 +44,14 @@ export type SessionApi = SessionState & {
43
44
  readonly switchTenant: (tenantId: string) => Promise<void>;
44
45
  };
45
46
 
47
+ const UNAUTHENTICATED: SessionState = {
48
+ status: "unauthenticated",
49
+ user: null,
50
+ activeTenantId: null,
51
+ tenants: [],
52
+ roles: [],
53
+ };
54
+
46
55
  const INITIAL: SessionState = {
47
56
  status: "loading",
48
57
  user: null,
@@ -51,6 +60,11 @@ const INITIAL: SessionState = {
51
60
  roles: [],
52
61
  };
53
62
 
63
+ // kumiko_auth ist HttpOnly — kumiko_csrf wird beim Login gemeinsam gesetzt.
64
+ export function hasLikelyAuthSession(cookieSource?: string): boolean {
65
+ return readCsrfToken(cookieSource) !== undefined;
66
+ }
67
+
54
68
  // Exported damit tests den merge-pfad direkt pinnen können — der hier
55
69
  // muss byte-identisch zum server-side merge in auth-routes.ts +
56
70
  // login.write.ts sein, sonst sieht der Client andere session-rollen
@@ -76,27 +90,18 @@ export function computeActiveRoles(
76
90
  export const SessionContext = createContext<SessionApi | undefined>(undefined);
77
91
 
78
92
  // Eine Refresh-Runde: /auth/tenants → wenn 401 nicht-eingeloggt, sonst
79
- // parallel /user:me. Beides zusammen ergibt den vollen SessionState.
93
+ // /user:me. Beides zusammen ergibt den vollen SessionState.
80
94
  async function refresh(): Promise<SessionState> {
95
+ if (!hasLikelyAuthSession()) {
96
+ return UNAUTHENTICATED;
97
+ }
81
98
  const tenants = await fetchTenants();
82
99
  if (tenants === null) {
83
- return {
84
- status: "unauthenticated",
85
- user: null,
86
- activeTenantId: null,
87
- tenants: [],
88
- roles: [],
89
- };
100
+ return UNAUTHENTICATED;
90
101
  }
91
102
  const user = await fetchCurrentUser();
92
103
  if (user === null) {
93
- return {
94
- status: "unauthenticated",
95
- user: null,
96
- activeTenantId: null,
97
- tenants: [],
98
- roles: [],
99
- };
104
+ return UNAUTHENTICATED;
100
105
  }
101
106
  return {
102
107
  status: "authenticated",
@@ -131,13 +136,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
131
136
 
132
137
  const logout = useCallback<SessionApi["logout"]>(async () => {
133
138
  await logoutApi();
134
- setState({
135
- status: "unauthenticated",
136
- user: null,
137
- activeTenantId: null,
138
- tenants: [],
139
- roles: [],
140
- });
139
+ setState(UNAUTHENTICATED);
141
140
  // Hard-Reload: React-Tree, dispatcher-live-Caches, EventSource —
142
141
  // alles fliegt auf Null. Nach Logout ist das der billigste Weg zu
143
142
  // sauberer Ausgangslage, ohne dass wir jeden einzelnen Consumer