sekisho 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,239 @@
1
+ <h1 align="center">⛩️ sekisho</h1>
2
+ <p align="center"><sup>(関所, <em>historical checkpoint for travel and security</em> in Japanese)</sup></p>
3
+ <p align="center">Authentication and Access Control for any React app ([online demo](https://sekisho-demo.pages.dev/login))</p>
4
+
5
+ ----
6
+
7
+ ## Usage
8
+
9
+ ### Full example
10
+
11
+ See the [example-nextjs-app](../../packages/example-nextjs-app). This example is deployed online at [https://sekisho-demo.pages.dev](https://sekisho-demo.pages.dev).
12
+
13
+ ### Simple setup
14
+
15
+ Wrap your app with `SekishoProvider` with options:
16
+
17
+ ```tsx
18
+ // app/providers.tsx
19
+
20
+ // here we create a dedicated file, as we want to make this file a client component in Next.js App Router
21
+ //
22
+ // if you don't use the Next.js App Router, you can just wrap your app's entrypoint with `SekishoProvider`
23
+ // directly without creating a separate file
24
+
25
+ 'use client';
26
+
27
+ import { SekishoProvider } from 'sekisho';
28
+ import { useRouter } from 'next/navigation';
29
+
30
+ export function Providers({ children }: React.PropsWithChildren) {
31
+ const router = useRouter();
32
+
33
+ return (
34
+ <SekishoProvider
35
+ onNeedLogin={() => {
36
+ // Tell Sekisho what to do when the authentication is required.
37
+ router.push('/login');
38
+ // Sekisho can work with any router or navigation library.
39
+ // You can also call `navigate` from React Router's `useNavigate` here:
40
+ // navigate('/login');
41
+ }}
42
+ >
43
+ {children}
44
+ </SekishoProvider>
45
+ );
46
+ }
47
+ ```
48
+
49
+ ```tsx
50
+ // app/layout.tsx
51
+ import { Providers } from './providers';
52
+
53
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
54
+ return (
55
+ <html lang="en">
56
+ <body>
57
+ <Providers>{children}</Providers>
58
+ </body>
59
+ </html>
60
+ );
61
+ }
62
+ ```
63
+
64
+ By default, `SekishoProvider` already includes `SekishoErrorBoundary` that will catch any `NotAuthenticatedError` thrown by `needLogin()` in the subtree. But if you have special error handling needs in certain parts of your app, you can also always import `SekishoErrorBoundary` directly to wrap those parts:
65
+
66
+ ```tsx
67
+ import { SekishoErrorBoundary } from 'sekisho';
68
+
69
+ function SomePartOfApp() {
70
+ return (
71
+ <SekishoErrorBoundary>
72
+ {/* ... */}
73
+ </SekishoErrorBoundary>
74
+ );
75
+ }
76
+ ```
77
+
78
+ And if you are using Next.js App Router and `error.tsx` file, due to Next.js layout, page, and error boundary heirarchy, you will also need to wrap the `error.tsx` with `SekishoErrorWrapper`:
79
+
80
+ ```tsx
81
+ // app/error.tsx
82
+ 'use client';
83
+
84
+ import { SekishoErrorWrapper } from 'sekisho';
85
+
86
+ export default function ErrorPage({ error, reset }) {
87
+ return (
88
+ <SekishoErrorWrapper error={error}>
89
+ {/* Your existing error UI goes in here */}
90
+ </SekishoErrorWrapper>
91
+ );
92
+ }
93
+ ```
94
+
95
+ > `SekishoErrorWrapper` is actually used by `SekishoErrorBoundary` internally, containing all the core logic.
96
+
97
+ ### Triggering a login redirect
98
+
99
+ Call `needLogin()` anywhere during the React render phase.
100
+
101
+ Right now, you can't call `needLogin()` within an event handler or `useEffect`, because it won't be caught by the React error boundary mechanism. We will be implementing this in a future version.
102
+
103
+ **In a client component** (e.g. when session state is absent):
104
+
105
+ ```tsx
106
+ import { needLogin } from 'sekisho';
107
+
108
+ function Dashboard() {
109
+ const session = useAuthSession();
110
+
111
+ if (!session) {
112
+ needLogin('No active session');
113
+ }
114
+
115
+ return <div>Welcome, {session.username}</div>;
116
+ }
117
+ ```
118
+
119
+ **In a [SWR](https://swr.vercel.app) middleware** (e.g. when an API response carries a known auth error):
120
+
121
+ ```tsx
122
+ import { needLogin } from 'sekisho';
123
+ import type { Middleware } from 'swr';
124
+
125
+ export const requireAuthMiddleware: Middleware = (useSWRNext) => (key, fetcher, config) => {
126
+ const swr = useSWRNext(key, fetcher, config);
127
+ if (swr.error && isApiAuthError(swr.error)) {
128
+ needLogin(swr.error.message);
129
+ }
130
+ return swr;
131
+ };
132
+ ```
133
+
134
+ ### Restricting access
135
+
136
+ Wrap any part of the UI with `SekishoAccessContainer` and call `accessRestricted()` inside it when the user lacks the required role or permission. Unlike `needLogin()`, which triggers a global redirect via `onNeedLogin`, `accessRestricted()` is local — `SekishoAccessContainer` simply renders `fallback` in place of its children:
137
+
138
+ ```tsx
139
+ import { accessRestricted, SekishoAccessContainer } from 'sekisho';
140
+
141
+ function AdminPanel() {
142
+ const { role } = useCurrentUser();
143
+
144
+ if (role !== 'admin') {
145
+ accessRestricted('Admin only');
146
+ }
147
+
148
+ return <div>Secret admin content</div>;
149
+ }
150
+
151
+ function Page() {
152
+ return (
153
+ <SekishoAccessContainer
154
+ fallback={<p>You don't have permission to view this section.</p>}
155
+ >
156
+ <AdminPanel />
157
+ </SekishoAccessContainer>
158
+ );
159
+ }
160
+ ```
161
+
162
+ This kinda like `<Suspense />` but for access control instead. And like `<Suspense />`, you can have multiple `SekishoAccessContainer`s nested independently — each one only catches the `accessRestricted()` calls within its own subtree.
163
+
164
+ ## Explanation
165
+
166
+ Sekisho is built on top of React's error boundaries. Both `needLogin()` and `accessRestricted()` throw a special tagged error during the React render phase, which bubbles up to the nearest matching boundary:
167
+
168
+ | Function | Error thrown | Caught by | Behaviour |
169
+ |---|---|---|---|
170
+ | `needLogin()` | `NotAuthenticatedError` | `SekishoErrorBoundary` / `SekishoErrorWrapper` | Calls `onNeedLogin` from `SekishoProvider` (global redirect) |
171
+ | `accessRestricted()` | `AccessRestrictedError` | `SekishoAccessContainer` | Renders the `fallback` prop in place of children (local swap) |
172
+
173
+ Each boundary re-throws errors it does not own, so `SekishoAccessContainer` never swallows an auth error, and `SekishoErrorBoundary` never swallows an access error. Your own error boundaries are unaffected by either.
174
+
175
+ With `SekishoErrorWrapper` / `SekishoErrorBoundary` you can create protected and unprotected routes in any React app:
176
+
177
+ **Next.js App Router**
178
+
179
+ ```
180
+ app/
181
+ ├── (protected)/ ← all protected routes goes under here
182
+ │ ├── layout.tsx ← wrap children with <SekishoProvider> here
183
+ │ ├── error.tsx ← wrap with <SekishoErrorWrapper> here
184
+ │ └── page.tsx ← homepage, where you call needLogin() when authentication is needed
185
+ ├── (unprotected)/ ← all unprotected routes goes under here
186
+ │ └── login/
187
+ │ └── page.tsx
188
+ └── layout.tsx ← root layout
189
+ ```
190
+
191
+ **React Router**
192
+
193
+ ```tsx
194
+ const router = createBrowserRouter([
195
+ {
196
+ component() {
197
+ return (
198
+ <RootLayout>
199
+ <SekishoProvider onNeedLogin={() => navigate('/login')}>
200
+ <Outlet />
201
+ </SekishoProvider>
202
+ </RootLayout>
203
+ );
204
+ },
205
+ children: [
206
+ {
207
+ component() {
208
+ return (
209
+ <SekishoErrorBoundary>
210
+ <Outlet />
211
+ </SekishoErrorBoundary>
212
+ );
213
+ },
214
+ children: [
215
+ // protected routes goes here
216
+ ]
217
+ },
218
+ // unprotected routes goes here
219
+ ]
220
+ }
221
+ ]);
222
+ ```
223
+
224
+ ## License
225
+
226
+ [MIT](LICENSE)
227
+
228
+ ----
229
+
230
+ **sekisho** © [Sukka](https://github.com/SukkaW), Released under the [MIT](./LICENSE) License.
231
+ Authored and maintained by Sukka with help from contributors ([list](https://github.com/SukkaW/sekisho/graphs/contributors)).
232
+
233
+ > [Personal Website](https://skk.moe) · [Blog](https://blog.skk.moe) · GitHub [@SukkaW](https://github.com/SukkaW) · Telegram Channel [@SukkaChannel](https://t.me/SukkaChannel) · Mastodon [@sukka@acg.mn](https://acg.mn/@sukka) · Twitter [@isukkaw](https://twitter.com/isukkaw) · BlueSky [@skk.moe](https://bsky.app/profile/skk.moe)
234
+
235
+ <p align="center">
236
+ <a href="https://github.com/sponsors/SukkaW/">
237
+ <img src="https://sponsor.cdn.skk.moe/sponsors.svg"/>
238
+ </a>
239
+ </p>
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use client";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("foxact/use-isomorphic-layout-effect"),r=require("foxact/nullthrow"),t=require("react"),o=require("foxact/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired"),i=require("react/jsx-runtime");let n=Object.getOwnPropertyDescriptor(Error,"stackTraceLimit"),s=n?.writable&&"number"==typeof n.value;class u extends Error{constructor(...e){super(...e),this.name="NotAuthenticatedError",this.notAuthenticated=!0,this.$sekishoError=!0}}function c(e){return!!e&&"object"==typeof e&&"notAuthenticated"in e&&!0===e.notAuthenticated&&"$sekishoError"in e&&!0===e.$sekishoError}let a=t.createContext(null);function l({error:i,children:n}){let{onNeedLogin:s}=r.nullthrow(t.useContext(a),"useSekishoOptions must be used within a SekishoOptionsProvider"),u=c(i),h=o.useStableHandler(s);return(e.useLayoutEffect(()=>{u&&h()},[u,h]),u)?null:n}class h extends t.Component{constructor(e){super(e),this.state={needLoginErrorObject:null}}static getDerivedStateFromError(e){if(c(e))return{needLoginErrorObject:e};throw e}render(){return this.state.needLoginErrorObject?i.jsx(l,{error:this.state.needLoginErrorObject,children:this.props.children}):this.props.children}}exports.NotAuthenticatedError=u,exports.SekishoErrorBoundary=h,exports.SekishoErrorWrapper=l,exports.SekishoProvider=function({children:e,...r}){return i.jsx(a.Provider,{value:r,children:i.jsx(h,{children:e})})},exports.isNeedLoginError=c,exports.needLogin=function(e){let r=Error.stackTraceLimit;s&&(Error.stackTraceLimit=0);let t=new u(e);throw s&&(Error.stackTraceLimit=r),t};
1
+ "use client";Object.defineProperty(exports,"__esModule",{value:!0});var r=require("foxact/use-isomorphic-layout-effect"),e=require("foxact/nullthrow"),t=require("react"),s=require("foxact/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired"),o=require("react/jsx-runtime");let i=Object.getOwnPropertyDescriptor(Error,"stackTraceLimit"),n=i?.writable&&"number"==typeof i.value;function c(r){let e=Error.stackTraceLimit;n&&(Error.stackTraceLimit=0);let t=r();return n&&(Error.stackTraceLimit=e),t}class u extends Error{constructor(...r){super(...r),this.name="NotAuthenticatedError",this.notAuthenticated=!0,this.$sekishoError=!0}}function a(r){return!!r&&"object"==typeof r&&"notAuthenticated"in r&&!0===r.notAuthenticated&&"$sekishoError"in r&&!0===r.$sekishoError}let h=t.createContext(null);function d({error:o,children:i}){let{onNeedLogin:n}=e.nullthrow(t.useContext(h),"useSekishoOptions must be used within a SekishoOptionsProvider"),c=a(o),u=s.useStableHandler(n);return(r.useLayoutEffect(()=>{c&&u()},[c,u]),c)?null:i}class l extends t.Component{constructor(r){super(r),this.state={needLoginErrorObject:null}}static getDerivedStateFromError(r){if(a(r))return{needLoginErrorObject:r};throw r}render(){return this.state.needLoginErrorObject?o.jsx(d,{error:this.state.needLoginErrorObject,children:this.props.children}):this.props.children}}class p extends Error{constructor(...r){super(...r),this.name="AccessRestrictedError",this.accessRestricted=!0,this.$sekishoError=!0}}function E(r){return!!r&&"object"==typeof r&&"accessRestricted"in r&&!0===r.accessRestricted&&"$sekishoError"in r&&!0===r.$sekishoError}class x extends t.Component{constructor(r){super(r),this.state={restricted:!1}}static getDerivedStateFromError(r){if(E(r))return{restricted:!0};throw r}render(){return this.state.restricted?this.props.fallback:this.props.children}}exports.AccessRestrictedError=p,exports.NotAuthenticatedError=u,exports.SekishoAccessContainer=x,exports.SekishoErrorBoundary=l,exports.SekishoErrorWrapper=d,exports.SekishoProvider=function({children:r,...e}){return o.jsx(h.Provider,{value:e,children:o.jsx(l,{children:r})})},exports.accessRestricted=function(r){throw c(()=>new p(r))},exports.isAccessRestrictedError=E,exports.isNeedLoginError=a,exports.needLogin=function(r){throw c(()=>new u(r))};
package/dist/index.d.ts CHANGED
@@ -2,14 +2,6 @@ import * as react from 'react';
2
2
  import { Component } from 'react';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
 
5
- declare global {
6
- interface ErrorConstructor {
7
- /**
8
- * The `Error.stackTraceLimit` property is v8 only, add this to the type with nullish
9
- */
10
- stackTraceLimit?: number;
11
- }
12
- }
13
5
  declare class NotAuthenticatedError extends Error {
14
6
  readonly name = "NotAuthenticatedError";
15
7
  readonly notAuthenticated = true;
@@ -17,10 +9,14 @@ declare class NotAuthenticatedError extends Error {
17
9
  }
18
10
  declare function isNeedLoginError(error: unknown): error is NotAuthenticatedError;
19
11
  /**
20
- * This is a shortcut function to throw a "Not Authenticated" error.
12
+ * Throw a `NotAuthenticatedError` from anywhere in the React render phase.
13
+ *
14
+ * The error is caught by the nearest `SekishoErrorWrapper` or
15
+ * `SekishoErrorBoundary`, which then calls the `onNeedLogin` callback supplied
16
+ * to `SekishoProvider`.
21
17
  *
22
- * This can be used in ky's interceptor hooks, when HTTP 401 is detected, we can simply call this
23
- * This can also be used in SWR middleware, when API response JSON contains a known "not authenticated" error, we can call this
18
+ * Common call sites: ky afterResponse hooks (HTTP 401), SWR middleware, or
19
+ * directly inside a component when session state is absent.
24
20
  */
25
21
  declare function needLogin(message: string): never;
26
22
 
@@ -54,5 +50,37 @@ interface SekishoProviderProps extends React.PropsWithChildren, SekishoOptions {
54
50
  }
55
51
  declare function SekishoProvider({ children, ...auth }: SekishoProviderProps): react_jsx_runtime.JSX.Element;
56
52
 
57
- export { NotAuthenticatedError, SekishoErrorBoundary, SekishoErrorWrapper, SekishoProvider, isNeedLoginError, needLogin };
58
- export type { SekishoErrorBoundaryProps, SekishoErrorWrapperProps, SekishoOptions, SekishoProviderProps };
53
+ declare class AccessRestrictedError extends Error {
54
+ readonly name = "AccessRestrictedError";
55
+ readonly accessRestricted = true;
56
+ readonly $sekishoError = true;
57
+ }
58
+ declare function isAccessRestrictedError(error: unknown): error is AccessRestrictedError;
59
+ /**
60
+ * Throw an `AccessRestrictedError` from anywhere in the React render phase.
61
+ *
62
+ * The error is caught by the nearest `SekishoAccessContainer`, which renders
63
+ * its `fallback` prop instead of its children. Any other error boundary in the
64
+ * tree (including `SekishoErrorBoundary`) re-throws it unchanged.
65
+ */
66
+ declare function accessRestricted(message: string): never;
67
+
68
+ interface SekishoAccessContainerProps extends React.PropsWithChildren {
69
+ fallback: React.ReactNode;
70
+ }
71
+ interface State {
72
+ restricted: boolean;
73
+ }
74
+ /**
75
+ * Error boundary that catches `AccessRestrictedError` thrown by
76
+ * `accessRestricted()` within its subtree and renders `fallback` in place of
77
+ * `children`. All other errors are re-thrown to the next boundary up the tree.
78
+ */
79
+ declare class SekishoAccessContainer extends Component<SekishoAccessContainerProps, State> {
80
+ constructor(props: SekishoAccessContainerProps);
81
+ static getDerivedStateFromError(this: void, error: unknown): State;
82
+ render(): React.ReactNode;
83
+ }
84
+
85
+ export { AccessRestrictedError, NotAuthenticatedError, SekishoAccessContainer, SekishoErrorBoundary, SekishoErrorWrapper, SekishoProvider, accessRestricted, isAccessRestrictedError, isNeedLoginError, needLogin };
86
+ export type { SekishoAccessContainerProps, SekishoErrorBoundaryProps, SekishoErrorWrapperProps, SekishoOptions, SekishoProviderProps };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- "use client";import{useLayoutEffect as r}from"foxact/use-isomorphic-layout-effect";import{nullthrow as e}from"foxact/nullthrow";import{createContext as t,useContext as o,Component as i}from"react";import{useStableHandler as n}from"foxact/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired";import{jsx as s}from"react/jsx-runtime";let c=Object.getOwnPropertyDescriptor(Error,"stackTraceLimit"),a=c?.writable&&"number"==typeof c.value;class u extends Error{constructor(...r){super(...r),this.name="NotAuthenticatedError",this.notAuthenticated=!0,this.$sekishoError=!0}}function h(r){return!!r&&"object"==typeof r&&"notAuthenticated"in r&&!0===r.notAuthenticated&&"$sekishoError"in r&&!0===r.$sekishoError}function l(r){let e=Error.stackTraceLimit;a&&(Error.stackTraceLimit=0);let t=new u(r);throw a&&(Error.stackTraceLimit=e),t}let d=t(null);function p({error:t,children:i}){let{onNeedLogin:s}=e(o(d),"useSekishoOptions must be used within a SekishoOptionsProvider"),c=h(t),a=n(s);return(r(()=>{c&&a()},[c,a]),c)?null:i}class m extends i{constructor(r){super(r),this.state={needLoginErrorObject:null}}static getDerivedStateFromError(r){if(h(r))return{needLoginErrorObject:r};throw r}render(){return this.state.needLoginErrorObject?s(p,{error:this.state.needLoginErrorObject,children:this.props.children}):this.props.children}}function f({children:r,...e}){return s(d.Provider,{value:e,children:s(m,{children:r})})}export{u as NotAuthenticatedError,m as SekishoErrorBoundary,p as SekishoErrorWrapper,f as SekishoProvider,h as isNeedLoginError,l as needLogin};
1
+ "use client";import{useLayoutEffect as r}from"foxact/use-isomorphic-layout-effect";import{nullthrow as e}from"foxact/nullthrow";import{createContext as t,useContext as o,Component as s}from"react";import{useStableHandler as i}from"foxact/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired";import{jsx as n}from"react/jsx-runtime";let c=Object.getOwnPropertyDescriptor(Error,"stackTraceLimit"),a=c?.writable&&"number"==typeof c.value;function h(r){let e=Error.stackTraceLimit;a&&(Error.stackTraceLimit=0);let t=r();return a&&(Error.stackTraceLimit=e),t}class u extends Error{constructor(...r){super(...r),this.name="NotAuthenticatedError",this.notAuthenticated=!0,this.$sekishoError=!0}}function d(r){return!!r&&"object"==typeof r&&"notAuthenticated"in r&&!0===r.notAuthenticated&&"$sekishoError"in r&&!0===r.$sekishoError}function l(r){throw h(()=>new u(r))}let p=t(null);function E({error:t,children:s}){let{onNeedLogin:n}=e(o(p),"useSekishoOptions must be used within a SekishoOptionsProvider"),c=d(t),a=i(n);return(r(()=>{c&&a()},[c,a]),c)?null:s}class f extends s{constructor(r){super(r),this.state={needLoginErrorObject:null}}static getDerivedStateFromError(r){if(d(r))return{needLoginErrorObject:r};throw r}render(){return this.state.needLoginErrorObject?n(E,{error:this.state.needLoginErrorObject,children:this.props.children}):this.props.children}}function m({children:r,...e}){return n(p.Provider,{value:e,children:n(f,{children:r})})}class k extends Error{constructor(...r){super(...r),this.name="AccessRestrictedError",this.accessRestricted=!0,this.$sekishoError=!0}}function w(r){return!!r&&"object"==typeof r&&"accessRestricted"in r&&!0===r.accessRestricted&&"$sekishoError"in r&&!0===r.$sekishoError}function b(r){throw h(()=>new k(r))}class g extends s{constructor(r){super(r),this.state={restricted:!1}}static getDerivedStateFromError(r){if(w(r))return{restricted:!0};throw r}render(){return this.state.restricted?this.props.fallback:this.props.children}}export{k as AccessRestrictedError,u as NotAuthenticatedError,g as SekishoAccessContainer,f as SekishoErrorBoundary,E as SekishoErrorWrapper,m as SekishoProvider,b as accessRestricted,w as isAccessRestrictedError,d as isNeedLoginError,l as needLogin};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sekisho",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Authentication and Access Control for any React app",
5
5
  "repository": {
6
6
  "type": "git",