@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 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
  [![npm version](https://img.shields.io/npm/v/%40eminuckan%2Fspine.svg)](https://www.npmjs.com/package/@eminuckan/spine)
5
5
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- Framework-agnostic SaaS primitives for authentication, identity, permissions, multi-tenancy, API access, realtime events, query configuration, and logging.
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 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.
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 primitives use Web `Request`/`Response` objects, not framework-specific response helpers
16
- - React Router adapter is intentionally thin today so other adapters can be added without changing the core contract
17
- - Extracted from repeated production-facing SaaS frontend infrastructure needs, then rebooted as an independent OSS package
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 modules should work anywhere a standard Web `Request` and `Response` exist
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 legacy aliases |
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
- ### 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
189
238
 
190
- 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.
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 { 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
+ }
205
256
 
206
257
  configureIdentityAPIFetcher(async (request) => {
207
- const config = await createAPIConfig(request, {
208
- requireTenant: false,
209
- includeAuth: true,
210
- });
258
+ const accessToken = await getAccessToken(request);
211
259
 
212
- const response = await fetch(`${config.basePath}/api/me/context`, {
213
- headers: config.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 config = await createAPIConfig(request, { requireTenant: false });
225
- const response = await fetch(`${config.basePath}/api/me/permissions?tenantId=${tenantId}`, {
226
- headers: config.headers,
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
- getAvailableTenants,
246
- getCurrentTenant,
296
+ getActiveTenant,
247
297
  initializeTenant,
248
298
  } from '@eminuckan/spine/tenant/server';
249
- import { fetchIdentityContext } from './identity.server';
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: fetchIdentityContext,
258
- });
259
-
260
- export {
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 identityContext = await getIdentityContext(request, session.user.sub);
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
- permissions: userInfo.permissions,
295
- currentTenant: userInfo.currentTenant,
317
+ memberships: context.memberships ?? [],
296
318
  };
297
319
  },
298
320
  });
299
321
 
300
- export { requirePermission };
322
+ export { getActiveTenant, initializeTenant };
301
323
  ```
302
324
 
303
- ### 3. Protect Routes
325
+ ### 3. Protect a Route
304
326
 
305
327
  ```ts
306
- // app/routes/_protected.tsx
328
+ // app/routes/dashboard.tsx
307
329
  import { authRoute, getAccessToken } from '@eminuckan/spine/react-router/server';
308
- import { getCurrentTenant, initializeTenant } from '~/lib/spine/tenant.server';
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 [accessToken, identityContext, currentTenant] = await Promise.all([
314
- getAccessToken(request),
315
- getIdentityContext(request, user.sub),
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
- identity: {
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 Client Providers
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 Backend Conventions Instead of Forking
395
+ ### 5. Configure Conventions Instead of Forking
392
396
 
393
397
  ```ts
394
- import {
395
- configureAuthClaimMapping,
396
- configureIdentityStore,
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
- ## Module Guides
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 being maintained as a reusable OSS foundation.
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: teams copy auth, tenant, permission, API, query, and realtime infrastructure between apps. The project has been rebooted as independent OSS with generic contracts, public docs, tests, issue templates, roadmap, and a maintenance plan for Codex-assisted review and release work.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eminuckan/spine",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Framework-agnostic SaaS primitives for auth, identity, multi-tenancy, permissions, and API access.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",