@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CODE_OF_CONDUCT.md +39 -0
  3. package/CONTRIBUTING.md +78 -0
  4. package/MAINTAINERS.md +43 -0
  5. package/README.md +170 -135
  6. package/ROADMAP.md +58 -0
  7. package/SECURITY.md +37 -0
  8. package/dist/access/evaluator.d.ts.map +1 -1
  9. package/dist/access/index.js +1 -1
  10. package/dist/access/types.d.ts +10 -2
  11. package/dist/access/types.d.ts.map +1 -1
  12. package/dist/{access-DlAqsVGQ.js → access-CGrsDMwu.js} +9 -7
  13. package/dist/access-CGrsDMwu.js.map +1 -0
  14. package/dist/api-client/api-config.server.d.ts +27 -0
  15. package/dist/api-client/api-config.server.d.ts.map +1 -1
  16. package/dist/api-client/error-codes.d.ts +6 -31
  17. package/dist/api-client/error-codes.d.ts.map +1 -1
  18. package/dist/api-client/index.js +2 -2
  19. package/dist/api-client/server.d.ts +1 -1
  20. package/dist/api-client/server.d.ts.map +1 -1
  21. package/dist/api-client/server.js +3 -3
  22. package/dist/{api-client-CZapXIF8.js → api-client-CgZUMukO.js} +9 -26
  23. package/dist/api-client-CgZUMukO.js.map +1 -0
  24. package/dist/auth/auth.server.d.ts.map +1 -1
  25. package/dist/auth/authorization-parameters.d.ts +5 -0
  26. package/dist/auth/authorization-parameters.d.ts.map +1 -0
  27. package/dist/auth/route-protection.server.d.ts +20 -4
  28. package/dist/auth/route-protection.server.d.ts.map +1 -1
  29. package/dist/auth/server.d.ts +1 -1
  30. package/dist/auth/server.d.ts.map +1 -1
  31. package/dist/auth/server.js +5 -5
  32. package/dist/auth/types.d.ts +24 -2
  33. package/dist/auth/types.d.ts.map +1 -1
  34. package/dist/{auth.server-B3ODGIX4.js → auth.server-D5oiz9aF.js} +62 -53
  35. package/dist/auth.server-D5oiz9aF.js.map +1 -0
  36. package/dist/{auth.server-wdKsjQsn.js → auth.server-lC94lmlx.js} +3 -3
  37. package/dist/identity/identity-context.server.d.ts +2 -0
  38. package/dist/identity/identity-context.server.d.ts.map +1 -1
  39. package/dist/identity/identity-store.d.ts +19 -2
  40. package/dist/identity/identity-store.d.ts.map +1 -1
  41. package/dist/identity/index.js +2 -2
  42. package/dist/identity/server.js +2 -2
  43. package/dist/identity/types.d.ts +3 -1
  44. package/dist/identity/types.d.ts.map +1 -1
  45. package/dist/{identity-yT2ijZF0.js → identity-C5un-xAs.js} +104 -60
  46. package/dist/identity-C5un-xAs.js.map +1 -0
  47. package/dist/index.js +8 -8
  48. package/dist/logging/index.js +1 -1
  49. package/dist/{logging-_h2mi3L9.js → logging-DviiAkLx.js} +2 -2
  50. package/dist/{logging-_h2mi3L9.js.map → logging-DviiAkLx.js.map} +1 -1
  51. package/dist/permissions/constants.d.ts +3 -0
  52. package/dist/permissions/constants.d.ts.map +1 -1
  53. package/dist/permissions/index.js +1 -1
  54. package/dist/permissions/permission-store.d.ts +1 -1
  55. package/dist/permissions/types.d.ts +1 -1
  56. package/dist/permissions/types.d.ts.map +1 -1
  57. package/dist/{permissions-OUK8Eql2.js → permissions-_n1hIG7Q.js} +6 -12
  58. package/dist/permissions-_n1hIG7Q.js.map +1 -0
  59. package/dist/query-client/types.d.ts +9 -9
  60. package/dist/query-client/types.d.ts.map +1 -1
  61. package/dist/query-client-Bh3TyB8W.js.map +1 -1
  62. package/dist/react-router/index.js +8 -8
  63. package/dist/react-router/server.js +14 -14
  64. package/dist/{redis-session-storage.server-D0-72FV-.js → redis-session-storage.server-v2VcpFsQ.js} +2 -2
  65. package/dist/{redis-session-storage.server-D0-72FV-.js.map → redis-session-storage.server-v2VcpFsQ.js.map} +1 -1
  66. package/dist/{server-B7JN86_0.js → server-B6PCxc-T.js} +31 -83
  67. package/dist/server-B6PCxc-T.js.map +1 -0
  68. package/dist/{server-DeBlxoaW.js → server-C96NMFBy.js} +17 -9
  69. package/dist/server-C96NMFBy.js.map +1 -0
  70. package/dist/{server-Ce9jQRdL.js → server-Ca-3DJf2.js} +44 -11
  71. package/dist/server-Ca-3DJf2.js.map +1 -0
  72. package/dist/{server-BmiuV28c.js → server-Dxsww9v9.js} +5 -4
  73. package/dist/server-Dxsww9v9.js.map +1 -0
  74. package/dist/server.js +14 -14
  75. package/dist/signalr/index.js +1 -1
  76. package/dist/signalr/types.d.ts +1 -1
  77. package/dist/{signalr-CSfCz1Ik.js → signalr-k-70VHKo.js} +3 -2
  78. package/dist/signalr-k-70VHKo.js.map +1 -0
  79. package/dist/tenant/index.js +2 -2
  80. package/dist/tenant/server.js +4 -4
  81. package/dist/tenant/tenant-context.d.ts +17 -1
  82. package/dist/tenant/tenant-context.d.ts.map +1 -1
  83. package/dist/tenant/tenant-cookie.server.d.ts.map +1 -1
  84. package/dist/tenant/tenant-store.d.ts +54 -9
  85. package/dist/tenant/tenant-store.d.ts.map +1 -1
  86. package/dist/tenant/tenant.server.d.ts +20 -1
  87. package/dist/tenant/tenant.server.d.ts.map +1 -1
  88. package/dist/tenant/types.d.ts +29 -9
  89. package/dist/tenant/types.d.ts.map +1 -1
  90. package/dist/tenant-DfLemb6n.js +384 -0
  91. package/dist/tenant-DfLemb6n.js.map +1 -0
  92. package/docs/adapters.md +87 -0
  93. package/docs/architecture.md +193 -0
  94. package/docs/backend-adaptation.md +249 -0
  95. package/docs/codex-for-oss-application.md +39 -0
  96. package/docs/codex-maintenance.md +47 -0
  97. package/docs/installation.md +213 -0
  98. package/docs/module-reference.md +207 -0
  99. package/docs/releasing.md +92 -0
  100. package/package.json +21 -3
  101. package/dist/access-DlAqsVGQ.js.map +0 -1
  102. package/dist/api-client-CZapXIF8.js.map +0 -1
  103. package/dist/auth.server-B3ODGIX4.js.map +0 -1
  104. package/dist/identity-yT2ijZF0.js.map +0 -1
  105. package/dist/permissions-OUK8Eql2.js.map +0 -1
  106. package/dist/server-B7JN86_0.js.map +0 -1
  107. package/dist/server-BmiuV28c.js.map +0 -1
  108. package/dist/server-Ce9jQRdL.js.map +0 -1
  109. package/dist/server-DeBlxoaW.js.map +0 -1
  110. package/dist/signalr-CSfCz1Ik.js.map +0 -1
  111. package/dist/tenant-BErqEENp.js +0 -256
  112. package/dist/tenant-BErqEENp.js.map +0 -1
package/README.md CHANGED
@@ -1,15 +1,23 @@
1
1
  # Spine
2
2
 
3
- Framework-agnostic SaaS primitives for authentication, identity, permissions, multi-tenancy, API access, realtime events, query configuration, and logging.
3
+ [![CI](https://github.com/eminuckan/spine/actions/workflows/ci.yml/badge.svg)](https://github.com/eminuckan/spine/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40eminuckan%2Fspine.svg)](https://www.npmjs.com/package/@eminuckan/spine)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
6
 
5
- Spine is designed around one idea: the reusable parts of a SaaS platform should live in a single package, while application policy and framework quirks should stay in thin adapters. Today the package ships a framework-agnostic core plus a React Router adapter surface. Future adapters can follow the same pattern.
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 primitives use Web `Request`/`Response` objects, not framework-specific response helpers
12
- - React Router adapter is intentionally thin today so other adapters can be added without changing the core contract
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 modules should work anywhere a standard Web `Request` and `Response` exist
31
- - App policy stays local: onboarding flows, subscription gates, product-specific redirects, and permission taxonomies should live in the consuming app
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 onboarding rules
72
- - Your product's subscription rules
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 legacy aliases |
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 | Default API base URL for `createAPIConfigFactory` |
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
- ### 1. Configure Identity and Tenant Adapters
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, so your app should provide backend-specific fetchers once.
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/mimir/identity.server.ts
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 { createAPIConfig } = createAPIConfigFactory(getAccessToken, getCurrentTenant);
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 config = await createAPIConfig(request, {
191
- requireTenant: false,
192
- includeAuth: true,
193
- });
258
+ const accessToken = await getAccessToken(request);
194
259
 
195
- const api = new IdentityApi(
196
- new Configuration({
197
- basePath: config.basePath,
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
- const response = await api.identityGetMyContext();
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 config = await createAPIConfig(request, { requireTenant: false });
217
- const api = new IdentityApi(
218
- new Configuration({
219
- basePath: config.basePath,
220
- accessToken: config.accessToken,
221
- baseOptions: { headers: config.headers },
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 response = await api.identityGetMyPermissions(tenantId);
226
- return response.data.permissions || [];
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/mimir/tenant.server.ts
292
+ // app/lib/spine/tenant.server.ts
234
293
  import {
235
- configureIdentityContextFetcher,
294
+ configureTenantResolution,
236
295
  configureTenantCookie,
237
- getAvailableTenants,
238
- getCurrentTenant,
296
+ getActiveTenant,
239
297
  initializeTenant,
240
298
  } from '@eminuckan/spine/tenant/server';
241
- import { fetchIdentityContext } from './identity.server';
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
- configureIdentityContextFetcher(fetchIdentityContext);
249
-
250
- export {
251
- getAvailableTenants,
252
- getCurrentTenant,
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 identityContext = await getIdentityContext(request, session.user.sub);
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
- permissions: userInfo.permissions,
285
- currentTenant: userInfo.currentTenant,
317
+ memberships: context.memberships ?? [],
286
318
  };
287
319
  },
288
320
  });
289
321
 
290
- export { requirePermission };
322
+ export { getActiveTenant, initializeTenant };
291
323
  ```
292
324
 
293
- ### 3. Protect Routes
325
+ ### 3. Protect a Route
294
326
 
295
327
  ```ts
296
- // app/routes/_protected.tsx
328
+ // app/routes/dashboard.tsx
297
329
  import { authRoute, getAccessToken } from '@eminuckan/spine/react-router/server';
298
- import { getCurrentTenant, initializeTenant } from '~/lib/mimir/tenant.server';
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 [accessToken, identityContext, currentTenant] = await Promise.all([
304
- getAccessToken(request),
305
- getIdentityContext(request, user.sub),
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
- identity: {
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 Client Providers
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 Backend Conventions Instead of Forking
395
+ ### 5. Configure Conventions Instead of Forking
382
396
 
383
397
  ```ts
384
- import {
385
- configureAuthClaimMapping,
386
- configureIdentityStore,
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: ['organizations', 'tenant_ids'],
392
- tenantRoles: ['organization_roles', 'tenant_roles'],
403
+ tenantIds: ['tenant_ids'],
404
+ tenantRoles: ['tenant_roles'],
393
405
  permissions: ['permissions', 'scope'],
394
- isOnboarded: ['profile_complete', 'is_onboarded'],
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
- ## Module Guides
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 onboarding pages and redirects
434
- - Product-specific subscription policies
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
- - More adapter authoring guidance
443
- - Cleaner permission taxonomy extension story
444
- - More example apps
445
- - Broader runtime coverage tests
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 typecheck
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,CAkDhB"}
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"}
@@ -1,4 +1,4 @@
1
1
  import "../permission-service-CvGpwI0d.js";
2
- import { evaluateAccessRequirement } from "../access-DlAqsVGQ.js";
2
+ import { evaluateAccessRequirement } from "../access-CGrsDMwu.js";
3
3
 
4
4
  export { evaluateAccessRequirement };
@@ -1,14 +1,18 @@
1
1
  import type { PermissionCode } from '../permissions/types';
2
2
  export type AccessOperator = 'AND' | 'OR';
3
- export interface SubscriptionAccessSnapshot {
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-subscription' | 'missing-permissions' | 'missing-features';
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,0BAA0B;IACzC,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,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAC;IAC/C,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,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,kBAAkB,GAC1B,eAAe,GACf,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"}
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"}