@eminuckan/spine 0.3.0 → 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 +6 -0
- package/README.md +116 -98
- package/docs/codex-for-oss-application.md +10 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
|
|
|
6
6
|
|
|
7
7
|
No unreleased changes.
|
|
8
8
|
|
|
9
|
+
## [0.3.1] - 2026-06-01
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Clarified README positioning, backend contract guidance, and quick-start snippets for the open source package.
|
|
14
|
+
|
|
9
15
|
## [0.3.0] - 2026-06-01
|
|
10
16
|
|
|
11
17
|
### Added
|
package/README.md
CHANGED
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@eminuckan/spine)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Framework-agnostic
|
|
7
|
+
Framework-agnostic TypeScript primitives for authentication, identity, permissions, multi-tenancy, API access, realtime events, query configuration, and logging in SaaS frontends.
|
|
8
8
|
|
|
9
|
-
Spine is designed around one idea: the reusable
|
|
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.
|
|
10
12
|
|
|
11
13
|
## Status
|
|
12
14
|
|
|
13
15
|
- Core package: `@eminuckan/spine`
|
|
14
16
|
- Current adapter: `@eminuckan/spine/react-router`
|
|
15
|
-
- Server
|
|
16
|
-
- React Router adapter is intentionally thin
|
|
17
|
-
-
|
|
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
|
|
18
21
|
|
|
19
22
|
## Why Spine
|
|
20
23
|
|
|
@@ -25,6 +28,7 @@ Spine aims to be:
|
|
|
25
28
|
- Framework-agnostic at its core
|
|
26
29
|
- Adapter-friendly for framework integration
|
|
27
30
|
- Config-driven for backend conventions
|
|
31
|
+
- Explicit about backend contracts instead of forcing one API shape
|
|
28
32
|
- Honest about boundaries between reusable primitives and app-specific policy
|
|
29
33
|
- Small enough to understand, flexible enough to extend
|
|
30
34
|
|
|
@@ -32,7 +36,7 @@ Spine aims to be:
|
|
|
32
36
|
|
|
33
37
|
- Core before adapter: framework-specific behavior belongs in dedicated adapter entry points
|
|
34
38
|
- Configure, do not fork: backend claim names, cookie behavior, endpoints, and redirects should be configured first
|
|
35
|
-
- Request/Response first: server
|
|
39
|
+
- Request/Response first: server-facing APIs should use standard Web `Request` and `Response` objects
|
|
36
40
|
- App policy stays local: setup flows, entitlement gates, product-specific redirects, and permission taxonomies should live in the consuming app
|
|
37
41
|
- Open for composition: apps should be able to wrap Spine primitives instead of rewriting them
|
|
38
42
|
|
|
@@ -41,7 +45,7 @@ Spine aims to be:
|
|
|
41
45
|
| Entry point | Purpose |
|
|
42
46
|
| --- | --- |
|
|
43
47
|
| `@eminuckan/spine` | Client-side primitives and shared types |
|
|
44
|
-
| `@eminuckan/spine/server` | Framework-agnostic server primitives |
|
|
48
|
+
| `@eminuckan/spine/server` | Framework-agnostic server primitives and shared exports |
|
|
45
49
|
| `@eminuckan/spine/react-router` | React Router client adapter |
|
|
46
50
|
| `@eminuckan/spine/react-router/server` | React Router server adapter |
|
|
47
51
|
| `@eminuckan/spine/auth` | Auth types |
|
|
@@ -81,6 +85,20 @@ Spine aims to be:
|
|
|
81
85
|
|
|
82
86
|
Those belong in the consuming app or in a product-specific adapter package.
|
|
83
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
|
+
|
|
84
102
|
## Architecture
|
|
85
103
|
|
|
86
104
|
```mermaid
|
|
@@ -139,7 +157,7 @@ The built-in auth/session layer currently reads these environment variables:
|
|
|
139
157
|
| `OIDC_CLIENT_AUTH_METHOD` | No | `none`, `client_secret_post`, or `client_secret_basic` |
|
|
140
158
|
| `OIDC_SCOPE` | No | Requested scope string. Defaults to `openid profile email api` |
|
|
141
159
|
| `OIDC_POST_LOGOUT_REDIRECT_URI` | No | Logout return URL |
|
|
142
|
-
| `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` |
|
|
143
161
|
| `OIDC_HAS_LANDING_PAGE` | No | Explicit landing-page behavior override |
|
|
144
162
|
| `OIDC_ALLOW_INSECURE_REQUESTS` | No | Allows non-HTTPS OIDC issuer calls only outside production |
|
|
145
163
|
| `REDIS_URL` | No | Redis connection string for sessions and OAuth state |
|
|
@@ -185,9 +203,40 @@ Back-channel logout verifies the signed `logout_token` against the provider JWKS
|
|
|
185
203
|
|
|
186
204
|
## Quick Start
|
|
187
205
|
|
|
188
|
-
|
|
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
|
|
189
238
|
|
|
190
|
-
Spine's server modules are generic
|
|
239
|
+
Spine's server modules are generic. Your app should provide backend-specific identity and tenant fetchers once.
|
|
191
240
|
|
|
192
241
|
```ts
|
|
193
242
|
// app/lib/spine/identity.server.ts
|
|
@@ -197,20 +246,19 @@ import {
|
|
|
197
246
|
contextToUserInfo,
|
|
198
247
|
getIdentityContext,
|
|
199
248
|
} from '@eminuckan/spine/identity/server';
|
|
200
|
-
import { createAPIConfigFactory } from '@eminuckan/spine/api-client/server';
|
|
201
249
|
import { getAccessToken } from '@eminuckan/spine/react-router/server';
|
|
202
|
-
import { getCurrentTenant } from '@eminuckan/spine/tenant/server';
|
|
203
250
|
|
|
204
|
-
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
|
+
}
|
|
205
256
|
|
|
206
257
|
configureIdentityAPIFetcher(async (request) => {
|
|
207
|
-
const
|
|
208
|
-
requireTenant: false,
|
|
209
|
-
includeAuth: true,
|
|
210
|
-
});
|
|
258
|
+
const accessToken = await getAccessToken(request);
|
|
211
259
|
|
|
212
|
-
const response = await fetch(`${
|
|
213
|
-
headers:
|
|
260
|
+
const response = await fetch(`${apiBaseUrl}/api/me/context`, {
|
|
261
|
+
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
214
262
|
});
|
|
215
263
|
|
|
216
264
|
if (!response.ok) {
|
|
@@ -221,9 +269,12 @@ configureIdentityAPIFetcher(async (request) => {
|
|
|
221
269
|
});
|
|
222
270
|
|
|
223
271
|
configurePermissionFetcher(async (request, tenantId) => {
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
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,
|
|
227
278
|
});
|
|
228
279
|
|
|
229
280
|
if (!response.ok) {
|
|
@@ -231,7 +282,7 @@ configurePermissionFetcher(async (request, tenantId) => {
|
|
|
231
282
|
}
|
|
232
283
|
|
|
233
284
|
const payload = await response.json();
|
|
234
|
-
return payload.permissions
|
|
285
|
+
return Array.isArray(payload.permissions) ? payload.permissions : [];
|
|
235
286
|
});
|
|
236
287
|
|
|
237
288
|
export { contextToUserInfo, getIdentityContext };
|
|
@@ -242,105 +293,58 @@ export { contextToUserInfo, getIdentityContext };
|
|
|
242
293
|
import {
|
|
243
294
|
configureTenantResolution,
|
|
244
295
|
configureTenantCookie,
|
|
245
|
-
|
|
246
|
-
getCurrentTenant,
|
|
296
|
+
getActiveTenant,
|
|
247
297
|
initializeTenant,
|
|
248
298
|
} from '@eminuckan/spine/tenant/server';
|
|
249
|
-
import {
|
|
299
|
+
import { getUser } from '@eminuckan/spine/react-router/server';
|
|
300
|
+
import { getIdentityContext } from './identity.server';
|
|
250
301
|
|
|
251
302
|
configureTenantCookie({
|
|
303
|
+
name: '__spine_tenant',
|
|
252
304
|
httpOnly: false,
|
|
253
305
|
sameSite: 'Lax',
|
|
254
306
|
});
|
|
255
307
|
|
|
256
308
|
configureTenantResolution({
|
|
257
|
-
identityContextFetcher:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
getAvailableTenants,
|
|
262
|
-
getCurrentTenant,
|
|
263
|
-
initializeTenant,
|
|
264
|
-
};
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
### 2. Configure Server-Side Permission Protection
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
// app/lib/spine/permissions.server.ts
|
|
271
|
-
import {
|
|
272
|
-
configurePermissionRouteProtection,
|
|
273
|
-
requirePermission,
|
|
274
|
-
} from '@eminuckan/spine/server';
|
|
275
|
-
import { getAuthSession } from '@eminuckan/spine/react-router/server';
|
|
276
|
-
import { contextToUserInfo, getIdentityContext } from './identity.server';
|
|
277
|
-
import { getCurrentTenant } from './tenant.server';
|
|
278
|
-
|
|
279
|
-
configurePermissionRouteProtection({
|
|
280
|
-
getSession: getAuthSession,
|
|
281
|
-
resolveContext: async (request, session) => {
|
|
282
|
-
if (!session.user?.sub) {
|
|
283
|
-
return { permissions: [], currentTenant: null };
|
|
309
|
+
identityContextFetcher: async (request) => {
|
|
310
|
+
const user = await getUser(request);
|
|
311
|
+
if (!user) {
|
|
312
|
+
return null;
|
|
284
313
|
}
|
|
285
314
|
|
|
286
|
-
const
|
|
287
|
-
const currentTenant = await getCurrentTenant(request);
|
|
288
|
-
const userInfo = await contextToUserInfo(identityContext, {
|
|
289
|
-
currentTenant,
|
|
290
|
-
request,
|
|
291
|
-
});
|
|
292
|
-
|
|
315
|
+
const context = await getIdentityContext(request, user.sub);
|
|
293
316
|
return {
|
|
294
|
-
|
|
295
|
-
currentTenant: userInfo.currentTenant,
|
|
317
|
+
memberships: context.memberships ?? [],
|
|
296
318
|
};
|
|
297
319
|
},
|
|
298
320
|
});
|
|
299
321
|
|
|
300
|
-
export {
|
|
322
|
+
export { getActiveTenant, initializeTenant };
|
|
301
323
|
```
|
|
302
324
|
|
|
303
|
-
### 3. Protect
|
|
325
|
+
### 3. Protect a Route
|
|
304
326
|
|
|
305
327
|
```ts
|
|
306
|
-
// app/routes/
|
|
328
|
+
// app/routes/dashboard.tsx
|
|
307
329
|
import { authRoute, getAccessToken } from '@eminuckan/spine/react-router/server';
|
|
308
|
-
import {
|
|
309
|
-
import { contextToUserInfo, getIdentityContext } from '~/lib/spine/identity.server';
|
|
330
|
+
import { getActiveTenant, initializeTenant } from '~/lib/spine/tenant.server';
|
|
310
331
|
|
|
311
332
|
export async function loader({ request }: { request: Request }) {
|
|
312
333
|
return authRoute(request, async (user) => {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
getCurrentTenant(request),
|
|
317
|
-
]);
|
|
318
|
-
|
|
319
|
-
const initResult =
|
|
320
|
-
!currentTenant && identityContext.hasAnyMembership
|
|
321
|
-
? await initializeTenant(request)
|
|
322
|
-
: null;
|
|
323
|
-
|
|
324
|
-
const userInfo = await contextToUserInfo(identityContext, {
|
|
325
|
-
currentTenant: initResult?.tenantId ?? currentTenant,
|
|
326
|
-
request,
|
|
327
|
-
});
|
|
334
|
+
const currentTenant = await getActiveTenant(request);
|
|
335
|
+
const initializedTenant = currentTenant ? null : await initializeTenant(request);
|
|
336
|
+
const accessToken = await getAccessToken(request);
|
|
328
337
|
|
|
329
338
|
return {
|
|
330
339
|
user,
|
|
331
|
-
accessToken,
|
|
332
|
-
|
|
333
|
-
...identityContext,
|
|
334
|
-
permissions: userInfo.permissions,
|
|
335
|
-
currentTenant: userInfo.currentTenant,
|
|
336
|
-
},
|
|
337
|
-
tenantHeaders: initResult?.headers ?? null,
|
|
340
|
+
hasAccessToken: Boolean(accessToken),
|
|
341
|
+
currentTenant: initializedTenant?.tenantId ?? currentTenant,
|
|
338
342
|
};
|
|
339
343
|
});
|
|
340
344
|
}
|
|
341
345
|
```
|
|
342
346
|
|
|
343
|
-
### 4. Wire
|
|
347
|
+
### 4. Wire React Providers
|
|
344
348
|
|
|
345
349
|
```tsx
|
|
346
350
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
@@ -388,14 +392,12 @@ export function AppProviders({
|
|
|
388
392
|
}
|
|
389
393
|
```
|
|
390
394
|
|
|
391
|
-
### 5. Configure
|
|
395
|
+
### 5. Configure Conventions Instead of Forking
|
|
392
396
|
|
|
393
397
|
```ts
|
|
394
|
-
import {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
configureRouteProtection,
|
|
398
|
-
} 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';
|
|
399
401
|
|
|
400
402
|
configureAuthClaimMapping({
|
|
401
403
|
tenantIds: ['tenant_ids'],
|
|
@@ -410,6 +412,17 @@ configureIdentityStore({
|
|
|
410
412
|
logoutPath: '/session/logout',
|
|
411
413
|
});
|
|
412
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
|
+
|
|
413
426
|
configureRouteProtection({
|
|
414
427
|
getLoginReturnUrl: ({ request }) => new URL(request.url).pathname,
|
|
415
428
|
});
|
|
@@ -419,20 +432,25 @@ Client contracts are configurable too. Simple apps can use endpoint configuratio
|
|
|
419
432
|
|
|
420
433
|
More adaptation examples live in [docs/backend-adaptation.md](docs/backend-adaptation.md).
|
|
421
434
|
|
|
422
|
-
##
|
|
435
|
+
## Documentation
|
|
436
|
+
|
|
437
|
+
Integration docs:
|
|
423
438
|
|
|
424
439
|
- [Architecture](docs/architecture.md)
|
|
425
440
|
- [Installation](docs/installation.md)
|
|
426
441
|
- [Adapters](docs/adapters.md)
|
|
427
442
|
- [Backend Adaptation](docs/backend-adaptation.md)
|
|
428
443
|
- [Module Reference](docs/module-reference.md)
|
|
444
|
+
|
|
445
|
+
Project docs:
|
|
446
|
+
|
|
429
447
|
- [Roadmap](ROADMAP.md)
|
|
430
|
-
- [Codex Maintenance Plan](docs/codex-maintenance.md)
|
|
431
448
|
- [Releasing](docs/releasing.md)
|
|
432
449
|
- [Contributing](CONTRIBUTING.md)
|
|
433
450
|
- [Maintainers](MAINTAINERS.md)
|
|
434
451
|
- [Security](SECURITY.md)
|
|
435
452
|
- [Code of Conduct](CODE_OF_CONDUCT.md)
|
|
453
|
+
- [Codex Maintenance Plan](docs/codex-maintenance.md)
|
|
436
454
|
|
|
437
455
|
## Current Boundaries
|
|
438
456
|
|
|
@@ -12,7 +12,15 @@ This is the maintainer-facing application packet for the Codex for Open Source p
|
|
|
12
12
|
|
|
13
13
|
## Qualification Draft
|
|
14
14
|
|
|
15
|
-
Spine is an MIT SaaS frontend infrastructure library for auth, identity, tenancy, permissions, API access, realtime clients, and framework adapters. It is extracted from repeated production-facing SaaS frontend needs, has a published npm package, docs, tests, CI, and an example app, and is
|
|
15
|
+
Spine is an MIT SaaS frontend infrastructure library for auth, identity, tenancy, permissions, API access, realtime clients, and framework adapters. It is extracted from repeated production-facing SaaS frontend needs, has a published npm package, docs, tests, CI, and an example app, and is maintained as reusable OSS.
|
|
16
|
+
|
|
17
|
+
## Recommended Qualification Answer
|
|
18
|
+
|
|
19
|
+
Spine is an MIT SaaS frontend infrastructure library for auth, identity, tenancy, permissions, API access, realtime, and framework adapters. It is early-stage (0 stars, 194 monthly npm downloads) but extracted from production-facing SaaS needs. In agentic coding workflows, it gives humans and agents a typed, documented substrate instead of rebuilding the same SaaS plumbing per app.
|
|
20
|
+
|
|
21
|
+
## Ecosystem Importance Draft
|
|
22
|
+
|
|
23
|
+
Agentic coding is making SaaS apps faster to build, but agents still spend context rebuilding auth, tenancy, permissions, API, query, and realtime plumbing. Spine gives humans and coding agents a typed, well-documented substrate they can install and compose instead of re-deriving the same infrastructure in every app.
|
|
16
24
|
|
|
17
25
|
## API Credit Use Draft
|
|
18
26
|
|
|
@@ -20,7 +28,7 @@ Use Codex for core OSS maintenance: PR review, issue triage, release checklist a
|
|
|
20
28
|
|
|
21
29
|
## Anything Else Draft
|
|
22
30
|
|
|
23
|
-
Spine is early-stage but solves a common repeated SaaS problem
|
|
31
|
+
Spine is early-stage, but it solves a common repeated SaaS problem and is especially useful in agentic development workflows. Its goal is to make the reusable infrastructure layer easy for both humans and AI coding agents to understand, install, and extend safely, while keeping product policy in each consuming app.
|
|
24
32
|
|
|
25
33
|
## Evidence To Refresh Before Submission
|
|
26
34
|
|
package/package.json
CHANGED