@eminuckan/spine 0.2.4 → 0.3.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/CHANGELOG.md +19 -0
- package/CODE_OF_CONDUCT.md +39 -0
- package/CONTRIBUTING.md +78 -0
- package/MAINTAINERS.md +43 -0
- package/README.md +170 -135
- package/ROADMAP.md +58 -0
- package/SECURITY.md +37 -0
- package/dist/access/evaluator.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/types.d.ts +10 -2
- package/dist/access/types.d.ts.map +1 -1
- package/dist/{access-DlAqsVGQ.js → access-CGrsDMwu.js} +9 -7
- package/dist/access-CGrsDMwu.js.map +1 -0
- package/dist/api-client/api-config.server.d.ts +27 -0
- package/dist/api-client/api-config.server.d.ts.map +1 -1
- package/dist/api-client/error-codes.d.ts +6 -31
- package/dist/api-client/error-codes.d.ts.map +1 -1
- package/dist/api-client/index.js +2 -2
- package/dist/api-client/server.d.ts +1 -1
- package/dist/api-client/server.d.ts.map +1 -1
- package/dist/api-client/server.js +3 -3
- package/dist/{api-client-CZapXIF8.js → api-client-CgZUMukO.js} +9 -26
- package/dist/api-client-CgZUMukO.js.map +1 -0
- package/dist/auth/auth.server.d.ts.map +1 -1
- package/dist/auth/authorization-parameters.d.ts +5 -0
- package/dist/auth/authorization-parameters.d.ts.map +1 -0
- package/dist/auth/route-protection.server.d.ts +20 -4
- package/dist/auth/route-protection.server.d.ts.map +1 -1
- package/dist/auth/server.d.ts +1 -1
- package/dist/auth/server.d.ts.map +1 -1
- package/dist/auth/server.js +5 -5
- package/dist/auth/types.d.ts +24 -2
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{auth.server-B3ODGIX4.js → auth.server-D5oiz9aF.js} +62 -53
- package/dist/auth.server-D5oiz9aF.js.map +1 -0
- package/dist/{auth.server-wdKsjQsn.js → auth.server-lC94lmlx.js} +3 -3
- package/dist/identity/identity-context.server.d.ts +2 -0
- package/dist/identity/identity-context.server.d.ts.map +1 -1
- package/dist/identity/identity-store.d.ts +19 -2
- package/dist/identity/identity-store.d.ts.map +1 -1
- package/dist/identity/index.js +2 -2
- package/dist/identity/server.js +2 -2
- package/dist/identity/types.d.ts +3 -1
- package/dist/identity/types.d.ts.map +1 -1
- package/dist/{identity-yT2ijZF0.js → identity-C5un-xAs.js} +104 -60
- package/dist/identity-C5un-xAs.js.map +1 -0
- package/dist/index.js +8 -8
- package/dist/logging/index.js +1 -1
- package/dist/{logging-_h2mi3L9.js → logging-DviiAkLx.js} +2 -2
- package/dist/{logging-_h2mi3L9.js.map → logging-DviiAkLx.js.map} +1 -1
- package/dist/permissions/constants.d.ts +3 -0
- package/dist/permissions/constants.d.ts.map +1 -1
- package/dist/permissions/index.js +1 -1
- package/dist/permissions/permission-store.d.ts +1 -1
- package/dist/permissions/types.d.ts +1 -1
- package/dist/permissions/types.d.ts.map +1 -1
- package/dist/{permissions-OUK8Eql2.js → permissions-_n1hIG7Q.js} +6 -12
- package/dist/permissions-_n1hIG7Q.js.map +1 -0
- package/dist/query-client/types.d.ts +9 -9
- package/dist/query-client/types.d.ts.map +1 -1
- package/dist/query-client-Bh3TyB8W.js.map +1 -1
- package/dist/react-router/index.js +8 -8
- package/dist/react-router/server.js +14 -14
- package/dist/{redis-session-storage.server-D0-72FV-.js → redis-session-storage.server-v2VcpFsQ.js} +2 -2
- package/dist/{redis-session-storage.server-D0-72FV-.js.map → redis-session-storage.server-v2VcpFsQ.js.map} +1 -1
- package/dist/{server-B7JN86_0.js → server-B6PCxc-T.js} +31 -83
- package/dist/server-B6PCxc-T.js.map +1 -0
- package/dist/{server-DeBlxoaW.js → server-C96NMFBy.js} +17 -9
- package/dist/server-C96NMFBy.js.map +1 -0
- package/dist/{server-Ce9jQRdL.js → server-Ca-3DJf2.js} +44 -11
- package/dist/server-Ca-3DJf2.js.map +1 -0
- package/dist/{server-BmiuV28c.js → server-Dxsww9v9.js} +5 -4
- package/dist/server-Dxsww9v9.js.map +1 -0
- package/dist/server.js +14 -14
- package/dist/signalr/index.js +1 -1
- package/dist/signalr/types.d.ts +1 -1
- package/dist/{signalr-CSfCz1Ik.js → signalr-k-70VHKo.js} +3 -2
- package/dist/signalr-k-70VHKo.js.map +1 -0
- package/dist/tenant/index.js +2 -2
- package/dist/tenant/server.js +4 -4
- package/dist/tenant/tenant-context.d.ts +17 -1
- package/dist/tenant/tenant-context.d.ts.map +1 -1
- package/dist/tenant/tenant-cookie.server.d.ts.map +1 -1
- package/dist/tenant/tenant-store.d.ts +54 -9
- package/dist/tenant/tenant-store.d.ts.map +1 -1
- package/dist/tenant/tenant.server.d.ts +20 -1
- package/dist/tenant/tenant.server.d.ts.map +1 -1
- package/dist/tenant/types.d.ts +29 -9
- package/dist/tenant/types.d.ts.map +1 -1
- package/dist/tenant-DfLemb6n.js +384 -0
- package/dist/tenant-DfLemb6n.js.map +1 -0
- package/docs/adapters.md +87 -0
- package/docs/architecture.md +193 -0
- package/docs/backend-adaptation.md +249 -0
- package/docs/codex-for-oss-application.md +39 -0
- package/docs/codex-maintenance.md +47 -0
- package/docs/installation.md +213 -0
- package/docs/module-reference.md +207 -0
- package/docs/releasing.md +92 -0
- package/package.json +21 -3
- package/dist/access-DlAqsVGQ.js.map +0 -1
- package/dist/api-client-CZapXIF8.js.map +0 -1
- package/dist/auth.server-B3ODGIX4.js.map +0 -1
- package/dist/identity-yT2ijZF0.js.map +0 -1
- package/dist/permissions-OUK8Eql2.js.map +0 -1
- package/dist/server-B7JN86_0.js.map +0 -1
- package/dist/server-BmiuV28c.js.map +0 -1
- package/dist/server-Ce9jQRdL.js.map +0 -1
- package/dist/server-DeBlxoaW.js.map +0 -1
- package/dist/signalr-CSfCz1Ik.js.map +0 -1
- package/dist/tenant-BErqEENp.js +0 -256
- package/dist/tenant-BErqEENp.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# Spine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/eminuckan/spine/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@eminuckan/spine)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Framework-agnostic TypeScript primitives for authentication, identity, permissions, multi-tenancy, API access, realtime events, query configuration, and logging in SaaS frontends.
|
|
8
|
+
|
|
9
|
+
Spine is designed around one idea: the reusable infrastructure in a SaaS frontend should live in a shared package, while product policy, backend DTOs, generated clients, and framework glue should stay in thin adapters owned by the consuming app.
|
|
10
|
+
|
|
11
|
+
The package is currently on a `0.x` release line. It is usable, tested, and published, but some compatibility aliases remain while the public API moves toward a cleaner `1.0` shape.
|
|
6
12
|
|
|
7
13
|
## Status
|
|
8
14
|
|
|
9
15
|
- Core package: `@eminuckan/spine`
|
|
10
16
|
- Current adapter: `@eminuckan/spine/react-router`
|
|
11
|
-
- Server
|
|
12
|
-
- React Router adapter is intentionally thin
|
|
17
|
+
- Server-facing APIs accept Web `Request`/`Response` objects instead of framework-specific response helpers
|
|
18
|
+
- React Router adapter is intentionally thin so future adapters can follow the same core contract
|
|
19
|
+
- Built-in auth uses OpenID Connect and Redis-backed session/OAuth state storage
|
|
20
|
+
- Extracted from repeated production-facing SaaS frontend infrastructure needs and maintained as independent OSS
|
|
13
21
|
|
|
14
22
|
## Why Spine
|
|
15
23
|
|
|
@@ -20,6 +28,7 @@ Spine aims to be:
|
|
|
20
28
|
- Framework-agnostic at its core
|
|
21
29
|
- Adapter-friendly for framework integration
|
|
22
30
|
- Config-driven for backend conventions
|
|
31
|
+
- Explicit about backend contracts instead of forcing one API shape
|
|
23
32
|
- Honest about boundaries between reusable primitives and app-specific policy
|
|
24
33
|
- Small enough to understand, flexible enough to extend
|
|
25
34
|
|
|
@@ -27,8 +36,8 @@ Spine aims to be:
|
|
|
27
36
|
|
|
28
37
|
- Core before adapter: framework-specific behavior belongs in dedicated adapter entry points
|
|
29
38
|
- Configure, do not fork: backend claim names, cookie behavior, endpoints, and redirects should be configured first
|
|
30
|
-
- Request/Response first: server
|
|
31
|
-
- App policy stays local:
|
|
39
|
+
- Request/Response first: server-facing APIs should use standard Web `Request` and `Response` objects
|
|
40
|
+
- App policy stays local: setup flows, entitlement gates, product-specific redirects, and permission taxonomies should live in the consuming app
|
|
32
41
|
- Open for composition: apps should be able to wrap Spine primitives instead of rewriting them
|
|
33
42
|
|
|
34
43
|
## Package Surfaces
|
|
@@ -36,7 +45,7 @@ Spine aims to be:
|
|
|
36
45
|
| Entry point | Purpose |
|
|
37
46
|
| --- | --- |
|
|
38
47
|
| `@eminuckan/spine` | Client-side primitives and shared types |
|
|
39
|
-
| `@eminuckan/spine/server` | Framework-agnostic server primitives |
|
|
48
|
+
| `@eminuckan/spine/server` | Framework-agnostic server primitives and shared exports |
|
|
40
49
|
| `@eminuckan/spine/react-router` | React Router client adapter |
|
|
41
50
|
| `@eminuckan/spine/react-router/server` | React Router server adapter |
|
|
42
51
|
| `@eminuckan/spine/auth` | Auth types |
|
|
@@ -68,14 +77,28 @@ Spine aims to be:
|
|
|
68
77
|
|
|
69
78
|
## What Spine Does Not Include
|
|
70
79
|
|
|
71
|
-
- Your product's
|
|
72
|
-
- Your product's
|
|
80
|
+
- Your product's setup rules
|
|
81
|
+
- Your product's billing or entitlement rules
|
|
73
82
|
- Your product's permission vocabulary
|
|
74
83
|
- Your backend DTOs or generated API clients
|
|
75
84
|
- Your page structure, layouts, or UI system
|
|
76
85
|
|
|
77
86
|
Those belong in the consuming app or in a product-specific adapter package.
|
|
78
87
|
|
|
88
|
+
## Backend Contract Model
|
|
89
|
+
|
|
90
|
+
Spine does not require your backend to expose a fixed set of endpoints. It provides defaults for simple apps, but real SaaS apps often have different vocabulary:
|
|
91
|
+
|
|
92
|
+
- tenant, workspace, account, company, project, or organization
|
|
93
|
+
- permissions, capabilities, scopes, roles, or policy grants
|
|
94
|
+
- bearer headers, custom session headers, cookie-backed APIs, or generated clients
|
|
95
|
+
|
|
96
|
+
The intended integration style is:
|
|
97
|
+
|
|
98
|
+
1. Configure claim names, cookie names, route policy, and API headers.
|
|
99
|
+
2. Provide fetchers when your identity, permission, or tenant response shape differs.
|
|
100
|
+
3. Keep product setup, billing, entitlement, and permission vocabulary in your app.
|
|
101
|
+
|
|
79
102
|
## Architecture
|
|
80
103
|
|
|
81
104
|
```mermaid
|
|
@@ -108,6 +131,19 @@ If you use client-side React features, install peer dependencies too:
|
|
|
108
131
|
pnpm add react @tanstack/react-query
|
|
109
132
|
```
|
|
110
133
|
|
|
134
|
+
For a fuller setup path, see [docs/installation.md](docs/installation.md).
|
|
135
|
+
|
|
136
|
+
## Try the Example App
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
cd examples/react-router-saas
|
|
140
|
+
pnpm install
|
|
141
|
+
cp .env.example .env
|
|
142
|
+
pnpm dev
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The example includes React Router auth routes, a protected dashboard loader, client provider wiring, and local app adapters for backend identity and tenant conventions.
|
|
146
|
+
|
|
111
147
|
## Environment Variables
|
|
112
148
|
|
|
113
149
|
The built-in auth/session layer currently reads these environment variables:
|
|
@@ -121,12 +157,12 @@ The built-in auth/session layer currently reads these environment variables:
|
|
|
121
157
|
| `OIDC_CLIENT_AUTH_METHOD` | No | `none`, `client_secret_post`, or `client_secret_basic` |
|
|
122
158
|
| `OIDC_SCOPE` | No | Requested scope string. Defaults to `openid profile email api` |
|
|
123
159
|
| `OIDC_POST_LOGOUT_REDIRECT_URI` | No | Logout return URL |
|
|
124
|
-
| `OIDC_APPLICATION_TYPE` | No | `no-landing-page`, `landing-page`, or
|
|
160
|
+
| `OIDC_APPLICATION_TYPE` | No | `no-landing-page`, `landing-page`, or deprecated aliases `dashboard` and `tenant-app` |
|
|
125
161
|
| `OIDC_HAS_LANDING_PAGE` | No | Explicit landing-page behavior override |
|
|
126
162
|
| `OIDC_ALLOW_INSECURE_REQUESTS` | No | Allows non-HTTPS OIDC issuer calls only outside production |
|
|
127
163
|
| `REDIS_URL` | No | Redis connection string for sessions and OAuth state |
|
|
128
164
|
| `REDIS_KEY_PREFIX` | No | Prefix for Redis keys |
|
|
129
|
-
| `API_BASE_URL` | No |
|
|
165
|
+
| `API_BASE_URL` | No | Optional fallback API base URL for `createAPIConfigFactory` |
|
|
130
166
|
|
|
131
167
|
## OIDC Session Lifecycle
|
|
132
168
|
|
|
@@ -167,170 +203,148 @@ Back-channel logout verifies the signed `logout_token` against the provider JWKS
|
|
|
167
203
|
|
|
168
204
|
## Quick Start
|
|
169
205
|
|
|
170
|
-
|
|
206
|
+
The snippets below show the integration shape. See [examples/react-router-saas](examples/react-router-saas) for a runnable React Router app.
|
|
207
|
+
|
|
208
|
+
### 1. Add Auth Routes
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// app/routes/auth.login.tsx
|
|
212
|
+
import { login } from '@eminuckan/spine/react-router/server';
|
|
213
|
+
|
|
214
|
+
export async function loader({ request }: { request: Request }) {
|
|
215
|
+
throw await login(request, { returnUrl: '/dashboard' });
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// app/routes/auth.callback.tsx
|
|
221
|
+
import { handleCallback } from '@eminuckan/spine/react-router/server';
|
|
222
|
+
|
|
223
|
+
export async function loader({ request }: { request: Request }) {
|
|
224
|
+
return handleCallback(request);
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// app/routes/auth.logout.tsx
|
|
230
|
+
import { logout } from '@eminuckan/spine/react-router/server';
|
|
231
|
+
|
|
232
|
+
export async function loader({ request }: { request: Request }) {
|
|
233
|
+
return logout(request);
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 2. Configure Server Adapters
|
|
171
238
|
|
|
172
|
-
Spine's server modules are generic
|
|
239
|
+
Spine's server modules are generic. Your app should provide backend-specific identity and tenant fetchers once.
|
|
173
240
|
|
|
174
241
|
```ts
|
|
175
|
-
// app/lib/
|
|
242
|
+
// app/lib/spine/identity.server.ts
|
|
176
243
|
import {
|
|
177
244
|
configureIdentityAPIFetcher,
|
|
178
245
|
configurePermissionFetcher,
|
|
179
246
|
contextToUserInfo,
|
|
180
247
|
getIdentityContext,
|
|
181
248
|
} from '@eminuckan/spine/identity/server';
|
|
182
|
-
import { createAPIConfigFactory } from '@eminuckan/spine/api-client/server';
|
|
183
249
|
import { getAccessToken } from '@eminuckan/spine/react-router/server';
|
|
184
|
-
import { getCurrentTenant } from '@eminuckan/spine/tenant/server';
|
|
185
|
-
import { Configuration, IdentityApi } from '~/lib/api-clients/api';
|
|
186
250
|
|
|
187
|
-
const
|
|
251
|
+
const apiBaseUrl = process.env.API_BASE_URL;
|
|
252
|
+
|
|
253
|
+
if (!apiBaseUrl) {
|
|
254
|
+
throw new Error('API_BASE_URL is required by this app adapter.');
|
|
255
|
+
}
|
|
188
256
|
|
|
189
257
|
configureIdentityAPIFetcher(async (request) => {
|
|
190
|
-
const
|
|
191
|
-
requireTenant: false,
|
|
192
|
-
includeAuth: true,
|
|
193
|
-
});
|
|
258
|
+
const accessToken = await getAccessToken(request);
|
|
194
259
|
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
accessToken: config.accessToken,
|
|
199
|
-
baseOptions: { headers: config.headers },
|
|
200
|
-
})
|
|
201
|
-
);
|
|
260
|
+
const response = await fetch(`${apiBaseUrl}/api/me/context`, {
|
|
261
|
+
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
262
|
+
});
|
|
202
263
|
|
|
203
|
-
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
throw new Error(`Failed to load identity context: ${response.status}`);
|
|
266
|
+
}
|
|
204
267
|
|
|
205
|
-
return
|
|
206
|
-
...response.data,
|
|
207
|
-
contextVersion: Date.now(),
|
|
208
|
-
hasSubscription: Boolean(
|
|
209
|
-
response.data.onboarding?.subscription?.exists &&
|
|
210
|
-
['active', 'trialing'].includes(response.data.onboarding?.subscription?.status || '')
|
|
211
|
-
),
|
|
212
|
-
};
|
|
268
|
+
return response.json();
|
|
213
269
|
});
|
|
214
270
|
|
|
215
271
|
configurePermissionFetcher(async (request, tenantId) => {
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
272
|
+
const accessToken = await getAccessToken(request);
|
|
273
|
+
const url = new URL('/api/me/permissions', apiBaseUrl);
|
|
274
|
+
url.searchParams.set('tenantId', tenantId);
|
|
275
|
+
|
|
276
|
+
const response = await fetch(url, {
|
|
277
|
+
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
224
283
|
|
|
225
|
-
const
|
|
226
|
-
return
|
|
284
|
+
const payload = await response.json();
|
|
285
|
+
return Array.isArray(payload.permissions) ? payload.permissions : [];
|
|
227
286
|
});
|
|
228
287
|
|
|
229
288
|
export { contextToUserInfo, getIdentityContext };
|
|
230
289
|
```
|
|
231
290
|
|
|
232
291
|
```ts
|
|
233
|
-
// app/lib/
|
|
292
|
+
// app/lib/spine/tenant.server.ts
|
|
234
293
|
import {
|
|
235
|
-
|
|
294
|
+
configureTenantResolution,
|
|
236
295
|
configureTenantCookie,
|
|
237
|
-
|
|
238
|
-
getCurrentTenant,
|
|
296
|
+
getActiveTenant,
|
|
239
297
|
initializeTenant,
|
|
240
298
|
} from '@eminuckan/spine/tenant/server';
|
|
241
|
-
import {
|
|
299
|
+
import { getUser } from '@eminuckan/spine/react-router/server';
|
|
300
|
+
import { getIdentityContext } from './identity.server';
|
|
242
301
|
|
|
243
302
|
configureTenantCookie({
|
|
303
|
+
name: '__spine_tenant',
|
|
244
304
|
httpOnly: false,
|
|
245
305
|
sameSite: 'Lax',
|
|
246
306
|
});
|
|
247
307
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
initializeTenant,
|
|
254
|
-
};
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
### 2. Configure Server-Side Permission Protection
|
|
258
|
-
|
|
259
|
-
```ts
|
|
260
|
-
// app/lib/mimir/permissions.server.ts
|
|
261
|
-
import {
|
|
262
|
-
configurePermissionRouteProtection,
|
|
263
|
-
requirePermission,
|
|
264
|
-
} from '@eminuckan/spine/server';
|
|
265
|
-
import { getAuthSession } from '@eminuckan/spine/react-router/server';
|
|
266
|
-
import { contextToUserInfo, getIdentityContext } from './identity.server';
|
|
267
|
-
import { getCurrentTenant } from './tenant.server';
|
|
268
|
-
|
|
269
|
-
configurePermissionRouteProtection({
|
|
270
|
-
getSession: getAuthSession,
|
|
271
|
-
resolveContext: async (request, session) => {
|
|
272
|
-
if (!session.user?.sub) {
|
|
273
|
-
return { permissions: [], currentTenant: null };
|
|
308
|
+
configureTenantResolution({
|
|
309
|
+
identityContextFetcher: async (request) => {
|
|
310
|
+
const user = await getUser(request);
|
|
311
|
+
if (!user) {
|
|
312
|
+
return null;
|
|
274
313
|
}
|
|
275
314
|
|
|
276
|
-
const
|
|
277
|
-
const currentTenant = await getCurrentTenant(request);
|
|
278
|
-
const userInfo = await contextToUserInfo(identityContext, {
|
|
279
|
-
currentTenant,
|
|
280
|
-
request,
|
|
281
|
-
});
|
|
282
|
-
|
|
315
|
+
const context = await getIdentityContext(request, user.sub);
|
|
283
316
|
return {
|
|
284
|
-
|
|
285
|
-
currentTenant: userInfo.currentTenant,
|
|
317
|
+
memberships: context.memberships ?? [],
|
|
286
318
|
};
|
|
287
319
|
},
|
|
288
320
|
});
|
|
289
321
|
|
|
290
|
-
export {
|
|
322
|
+
export { getActiveTenant, initializeTenant };
|
|
291
323
|
```
|
|
292
324
|
|
|
293
|
-
### 3. Protect
|
|
325
|
+
### 3. Protect a Route
|
|
294
326
|
|
|
295
327
|
```ts
|
|
296
|
-
// app/routes/
|
|
328
|
+
// app/routes/dashboard.tsx
|
|
297
329
|
import { authRoute, getAccessToken } from '@eminuckan/spine/react-router/server';
|
|
298
|
-
import {
|
|
299
|
-
import { contextToUserInfo, getIdentityContext } from '~/lib/mimir/identity.server';
|
|
330
|
+
import { getActiveTenant, initializeTenant } from '~/lib/spine/tenant.server';
|
|
300
331
|
|
|
301
332
|
export async function loader({ request }: { request: Request }) {
|
|
302
333
|
return authRoute(request, async (user) => {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
getCurrentTenant(request),
|
|
307
|
-
]);
|
|
308
|
-
|
|
309
|
-
const initResult =
|
|
310
|
-
!currentTenant && identityContext.hasAnyMembership
|
|
311
|
-
? await initializeTenant(request)
|
|
312
|
-
: null;
|
|
313
|
-
|
|
314
|
-
const userInfo = await contextToUserInfo(identityContext, {
|
|
315
|
-
currentTenant: initResult?.tenantId ?? currentTenant,
|
|
316
|
-
request,
|
|
317
|
-
});
|
|
334
|
+
const currentTenant = await getActiveTenant(request);
|
|
335
|
+
const initializedTenant = currentTenant ? null : await initializeTenant(request);
|
|
336
|
+
const accessToken = await getAccessToken(request);
|
|
318
337
|
|
|
319
338
|
return {
|
|
320
339
|
user,
|
|
321
|
-
accessToken,
|
|
322
|
-
|
|
323
|
-
...identityContext,
|
|
324
|
-
permissions: userInfo.permissions,
|
|
325
|
-
currentTenant: userInfo.currentTenant,
|
|
326
|
-
},
|
|
327
|
-
tenantHeaders: initResult?.headers ?? null,
|
|
340
|
+
hasAccessToken: Boolean(accessToken),
|
|
341
|
+
currentTenant: initializedTenant?.tenantId ?? currentTenant,
|
|
328
342
|
};
|
|
329
343
|
});
|
|
330
344
|
}
|
|
331
345
|
```
|
|
332
346
|
|
|
333
|
-
### 4. Wire
|
|
347
|
+
### 4. Wire React Providers
|
|
334
348
|
|
|
335
349
|
```tsx
|
|
336
350
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
@@ -378,20 +392,18 @@ export function AppProviders({
|
|
|
378
392
|
}
|
|
379
393
|
```
|
|
380
394
|
|
|
381
|
-
### 5. Configure
|
|
395
|
+
### 5. Configure Conventions Instead of Forking
|
|
382
396
|
|
|
383
397
|
```ts
|
|
384
|
-
import {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
configureRouteProtection,
|
|
388
|
-
} from '@eminuckan/spine/server';
|
|
398
|
+
import { configureAuthClaimMapping, configureRouteProtection } from '@eminuckan/spine/server';
|
|
399
|
+
import { configureIdentityStore } from '@eminuckan/spine/identity';
|
|
400
|
+
import { configureTenantClient } from '@eminuckan/spine/tenant';
|
|
389
401
|
|
|
390
402
|
configureAuthClaimMapping({
|
|
391
|
-
tenantIds: ['
|
|
392
|
-
tenantRoles: ['
|
|
403
|
+
tenantIds: ['tenant_ids'],
|
|
404
|
+
tenantRoles: ['tenant_roles'],
|
|
393
405
|
permissions: ['permissions', 'scope'],
|
|
394
|
-
isOnboarded: ['
|
|
406
|
+
isOnboarded: ['is_onboarded'],
|
|
395
407
|
});
|
|
396
408
|
|
|
397
409
|
configureIdentityStore({
|
|
@@ -400,22 +412,45 @@ configureIdentityStore({
|
|
|
400
412
|
logoutPath: '/session/logout',
|
|
401
413
|
});
|
|
402
414
|
|
|
415
|
+
configureTenantClient({
|
|
416
|
+
fetchTenantData: async ({ tenantId }) => {
|
|
417
|
+
const response = await fetch(`/api/workspaces/${tenantId}`);
|
|
418
|
+
const payload = await response.json();
|
|
419
|
+
return {
|
|
420
|
+
id: payload.workspace.id,
|
|
421
|
+
name: payload.workspace.displayName,
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
403
426
|
configureRouteProtection({
|
|
404
427
|
getLoginReturnUrl: ({ request }) => new URL(request.url).pathname,
|
|
405
428
|
});
|
|
406
429
|
```
|
|
407
430
|
|
|
431
|
+
Client contracts are configurable too. Simple apps can use endpoint configuration; apps with workspace/account/customer-specific contracts can provide `fetchTenantData`, `switchTenant`, `fetchContext`, and `fetchPermissions` functions. API header names are configurable through `createAPIConfigFactory`.
|
|
432
|
+
|
|
408
433
|
More adaptation examples live in [docs/backend-adaptation.md](docs/backend-adaptation.md).
|
|
409
434
|
|
|
410
|
-
##
|
|
435
|
+
## Documentation
|
|
436
|
+
|
|
437
|
+
Integration docs:
|
|
411
438
|
|
|
412
439
|
- [Architecture](docs/architecture.md)
|
|
440
|
+
- [Installation](docs/installation.md)
|
|
413
441
|
- [Adapters](docs/adapters.md)
|
|
414
442
|
- [Backend Adaptation](docs/backend-adaptation.md)
|
|
415
443
|
- [Module Reference](docs/module-reference.md)
|
|
444
|
+
|
|
445
|
+
Project docs:
|
|
446
|
+
|
|
447
|
+
- [Roadmap](ROADMAP.md)
|
|
416
448
|
- [Releasing](docs/releasing.md)
|
|
417
449
|
- [Contributing](CONTRIBUTING.md)
|
|
450
|
+
- [Maintainers](MAINTAINERS.md)
|
|
418
451
|
- [Security](SECURITY.md)
|
|
452
|
+
- [Code of Conduct](CODE_OF_CONDUCT.md)
|
|
453
|
+
- [Codex Maintenance Plan](docs/codex-maintenance.md)
|
|
419
454
|
|
|
420
455
|
## Current Boundaries
|
|
421
456
|
|
|
@@ -430,27 +465,27 @@ Spine already owns the reusable infrastructure for:
|
|
|
430
465
|
|
|
431
466
|
Consuming apps should still own:
|
|
432
467
|
|
|
433
|
-
- Product-specific
|
|
434
|
-
- Product-specific
|
|
468
|
+
- Product-specific setup pages and redirects
|
|
469
|
+
- Product-specific billing or entitlement policies
|
|
435
470
|
- Permission constants and domain vocabulary
|
|
436
471
|
- Generated API clients
|
|
437
472
|
- UI-specific wrappers and design system components
|
|
438
473
|
|
|
439
474
|
## Roadmap
|
|
440
475
|
|
|
476
|
+
The active roadmap lives in [ROADMAP.md](ROADMAP.md). Current tracks include:
|
|
477
|
+
|
|
441
478
|
- First-class Next.js adapter surface
|
|
442
|
-
-
|
|
443
|
-
-
|
|
444
|
-
-
|
|
445
|
-
-
|
|
479
|
+
- Clerk and Supabase integration guidance
|
|
480
|
+
- More adapter authoring tests and examples
|
|
481
|
+
- Cleaner migration from deprecated compatibility aliases
|
|
482
|
+
- Public API stability review before `1.0`
|
|
446
483
|
|
|
447
484
|
## Development
|
|
448
485
|
|
|
449
486
|
```bash
|
|
450
487
|
pnpm install
|
|
451
|
-
pnpm
|
|
452
|
-
pnpm build
|
|
453
|
-
pnpm lint
|
|
488
|
+
pnpm check
|
|
454
489
|
```
|
|
455
490
|
|
|
456
491
|
Detailed contributor guidance lives in [CONTRIBUTING.md](CONTRIBUTING.md).
|
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Spine Roadmap
|
|
2
|
+
|
|
3
|
+
Spine is a small, adapter-friendly foundation for SaaS frontend infrastructure. The roadmap favors reusable primitives, clear framework adapters, and backend-contract flexibility over product-specific features.
|
|
4
|
+
|
|
5
|
+
## Current Focus
|
|
6
|
+
|
|
7
|
+
- Keep the core framework-agnostic and based on Web `Request` and `Response` primitives.
|
|
8
|
+
- Keep application policy, permission vocabularies, setup flows, and entitlement rules in consuming apps.
|
|
9
|
+
- Make every backend convention configurable before adding a new abstraction.
|
|
10
|
+
- Preserve compatibility for existing consumers while moving toward a cleaner `1.0` API.
|
|
11
|
+
|
|
12
|
+
## Near-Term
|
|
13
|
+
|
|
14
|
+
- Publish the `0.3.x` open source reboot with generic tenant, identity, permission, API, realtime, and adapter surfaces.
|
|
15
|
+
- Expand the React Router example into a fuller SaaS shell with auth, tenant switching, permission gates, and API configuration.
|
|
16
|
+
- Add adapter authoring tests that prove new adapters can stay thin.
|
|
17
|
+
- Add more normalization examples for account, workspace, organization, and custom tenant contracts.
|
|
18
|
+
- Document migration notes from deprecated organization and subscription aliases to tenant and entitlement terminology.
|
|
19
|
+
|
|
20
|
+
## Adapter Track
|
|
21
|
+
|
|
22
|
+
- First-class Next.js adapter entry points.
|
|
23
|
+
- React Router adapter refinements where framework-specific helpers remove real boilerplate.
|
|
24
|
+
- Research notes for Vue and Svelte adapter shapes.
|
|
25
|
+
- Adapter request template and contribution checklist for community proposals.
|
|
26
|
+
|
|
27
|
+
## Provider Track
|
|
28
|
+
|
|
29
|
+
- Clerk identity/session adapter guidance.
|
|
30
|
+
- Supabase auth and Postgres-backed tenant examples.
|
|
31
|
+
- Auth0 and generic OpenID Connect recipes.
|
|
32
|
+
- Optional examples for API clients generated from OpenAPI without making any generated client mandatory.
|
|
33
|
+
|
|
34
|
+
## Core Hardening
|
|
35
|
+
|
|
36
|
+
- Broader tests for route protection and OIDC/session lifecycle behavior.
|
|
37
|
+
- More explicit storage abstraction boundaries for sessions and OAuth state.
|
|
38
|
+
- Runtime compatibility notes for Node, edge-like runtimes, and serverless deployments.
|
|
39
|
+
- Public API stability review before `1.0`.
|
|
40
|
+
|
|
41
|
+
## Not Planned For Core
|
|
42
|
+
|
|
43
|
+
- Product-specific route names.
|
|
44
|
+
- Product-specific permission constants.
|
|
45
|
+
- Billing plan vocabulary.
|
|
46
|
+
- Backend DTOs or generated SDKs.
|
|
47
|
+
- UI components tied to one design system.
|
|
48
|
+
|
|
49
|
+
Those belong in consuming apps or app-specific adapter packages.
|
|
50
|
+
|
|
51
|
+
## How To Propose Work
|
|
52
|
+
|
|
53
|
+
Open an issue using the relevant template and describe:
|
|
54
|
+
|
|
55
|
+
- the repeated SaaS infrastructure problem
|
|
56
|
+
- whether it belongs in core, a framework adapter, provider guidance, or the consuming app
|
|
57
|
+
- how the API can remain generic across multiple projects
|
|
58
|
+
- what tests or docs would prove the behavior
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Security fixes are applied to the latest published release line.
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
Please do not open public GitHub issues for security problems.
|
|
10
|
+
|
|
11
|
+
Instead, report vulnerabilities privately to:
|
|
12
|
+
|
|
13
|
+
- Email: `eminuckan@gmail.com`
|
|
14
|
+
|
|
15
|
+
When possible, include:
|
|
16
|
+
|
|
17
|
+
- A clear description of the issue
|
|
18
|
+
- Affected versions
|
|
19
|
+
- Reproduction steps
|
|
20
|
+
- Potential impact
|
|
21
|
+
- Any suggested mitigation
|
|
22
|
+
|
|
23
|
+
## Response Expectations
|
|
24
|
+
|
|
25
|
+
The goal is to acknowledge reports quickly, validate impact, and coordinate a fix before public disclosure.
|
|
26
|
+
|
|
27
|
+
## Scope Notes
|
|
28
|
+
|
|
29
|
+
Spine provides infrastructure primitives, but consuming applications are still responsible for:
|
|
30
|
+
|
|
31
|
+
- Backend authorization enforcement
|
|
32
|
+
- Secure secret management
|
|
33
|
+
- Correct adapter configuration
|
|
34
|
+
- Safe redirect and cookie policies
|
|
35
|
+
- Protecting product-specific APIs and workflows
|
|
36
|
+
|
|
37
|
+
Frontend permission checks and route guards in Spine improve UX, but they are not a substitute for backend authorization.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../src/access/evaluator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EAGd,iBAAiB,EACjB,aAAa,EAEd,MAAM,SAAS,CAAC;AA0CjB,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EACjD,OAAO,EAAE,aAAa,GACrB,cAAc,
|
|
1
|
+
{"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../src/access/evaluator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EAGd,iBAAiB,EACjB,aAAa,EAEd,MAAM,SAAS,CAAC;AA0CjB,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EACjD,OAAO,EAAE,aAAa,GACrB,cAAc,CAqDhB"}
|
package/dist/access/index.js
CHANGED
package/dist/access/types.d.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { PermissionCode } from '../permissions/types';
|
|
2
2
|
export type AccessOperator = 'AND' | 'OR';
|
|
3
|
-
export interface
|
|
3
|
+
export interface EntitlementAccessSnapshot {
|
|
4
4
|
exists?: boolean;
|
|
5
5
|
status?: string | null;
|
|
6
6
|
planCode?: string | null;
|
|
7
7
|
featureKeys?: readonly string[] | null;
|
|
8
8
|
limits?: Record<string, unknown> | null;
|
|
9
9
|
}
|
|
10
|
+
/** @deprecated Use EntitlementAccessSnapshot instead. */
|
|
11
|
+
export type SubscriptionAccessSnapshot = EntitlementAccessSnapshot;
|
|
10
12
|
export interface AccessSubject {
|
|
11
13
|
permissions?: readonly PermissionCode[] | null;
|
|
14
|
+
entitlement?: EntitlementAccessSnapshot | null;
|
|
15
|
+
/** @deprecated Use entitlement instead. */
|
|
12
16
|
subscription?: SubscriptionAccessSnapshot | null;
|
|
13
17
|
isInternalUser?: boolean;
|
|
14
18
|
}
|
|
@@ -17,10 +21,14 @@ export interface AccessRequirement {
|
|
|
17
21
|
permissionOperator?: AccessOperator;
|
|
18
22
|
features?: readonly string[];
|
|
19
23
|
featureOperator?: AccessOperator;
|
|
24
|
+
requireActiveEntitlement?: boolean;
|
|
25
|
+
/** @deprecated Use requireActiveEntitlement instead. */
|
|
20
26
|
requireActiveSubscription?: boolean;
|
|
21
27
|
internalOnly?: boolean;
|
|
22
28
|
}
|
|
23
|
-
export type AccessDeniedReason = 'internal-only' | 'inactive-
|
|
29
|
+
export type AccessDeniedReason = 'internal-only' | 'inactive-entitlement'
|
|
30
|
+
/** @deprecated Use inactive-entitlement instead. */
|
|
31
|
+
| 'inactive-subscription' | 'missing-permissions' | 'missing-features';
|
|
24
32
|
export interface AccessDecision {
|
|
25
33
|
allowed: boolean;
|
|
26
34
|
reasons: AccessDeniedReason[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/access/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,IAAI,CAAC;AAE1C,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/access/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,IAAI,CAAC;AAE1C,MAAM,WAAW,yBAAyB;IACxC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC;AAED,yDAAyD;AACzD,MAAM,MAAM,0BAA0B,GAAG,yBAAyB,CAAC;AAEnE,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAC;IAC/C,WAAW,CAAC,EAAE,yBAAyB,GAAG,IAAI,CAAC;IAC/C,2CAA2C;IAC3C,YAAY,CAAC,EAAE,0BAA0B,GAAG,IAAI,CAAC;IACjD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACxC,kBAAkB,CAAC,EAAE,cAAc,CAAC;IACpC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,eAAe,CAAC,EAAE,cAAc,CAAC;IACjC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC,wDAAwD;IACxD,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,kBAAkB,GAC1B,eAAe,GACf,sBAAsB;AACxB,oDAAoD;GAClD,uBAAuB,GACvB,qBAAqB,GACrB,kBAAkB,CAAC;AAEvB,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,kBAAkB,EAAE,cAAc,EAAE,CAAC;IACrC,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B"}
|