@eminuckan/spine 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +13 -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 +66 -49
  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 +31 -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
@@ -0,0 +1,213 @@
1
+ # Installation
2
+
3
+ This guide gets a new app from zero to a working Spine integration shape.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 20 or newer
8
+ - pnpm 9 or newer
9
+ - React 18 or newer when using client-side provider/hooks
10
+ - A Redis-compatible store for server sessions and OAuth state
11
+ - An OpenID Connect provider for the built-in auth flow
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pnpm add @eminuckan/spine
17
+ ```
18
+
19
+ For React apps, install the peer dependencies used by the provider and query modules:
20
+
21
+ ```bash
22
+ pnpm add react @tanstack/react-query
23
+ ```
24
+
25
+ ## Environment
26
+
27
+ ```bash
28
+ OIDC_AUTHORITY=https://identity.example.com/realms/demo
29
+ OIDC_CLIENT_ID=your-client
30
+ OIDC_REDIRECT_URI=http://localhost:5173/auth/callback
31
+ OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
32
+ OIDC_CLIENT_AUTH_METHOD=none
33
+ OIDC_SCOPE="openid profile email api"
34
+ REDIS_URL=redis://localhost:6379
35
+ API_BASE_URL=http://localhost:8080
36
+ ```
37
+
38
+ Use `OIDC_CLIENT_SECRET` with `OIDC_CLIENT_AUTH_METHOD=client_secret_post` or
39
+ `client_secret_basic` for confidential server-side clients.
40
+
41
+ ## React Router Routes
42
+
43
+ ```ts
44
+ // app/routes/auth.login.tsx
45
+ import { login } from '@eminuckan/spine/react-router/server';
46
+
47
+ export async function loader({ request }: { request: Request }) {
48
+ throw await login(request, { returnUrl: '/dashboard' });
49
+ }
50
+ ```
51
+
52
+ ```ts
53
+ // app/routes/auth.callback.tsx
54
+ import { handleCallback } from '@eminuckan/spine/react-router/server';
55
+
56
+ export async function loader({ request }: { request: Request }) {
57
+ return handleCallback(request);
58
+ }
59
+ ```
60
+
61
+ ```ts
62
+ // app/routes/auth.logout.tsx
63
+ import { logout } from '@eminuckan/spine/react-router/server';
64
+
65
+ export async function loader({ request }: { request: Request }) {
66
+ return logout(request);
67
+ }
68
+ ```
69
+
70
+ ```ts
71
+ // app/routes/dashboard.tsx
72
+ import { authRoute } from '@eminuckan/spine/react-router/server';
73
+
74
+ export async function loader({ request }: { request: Request }) {
75
+ return authRoute(request, async (user) => ({ user }));
76
+ }
77
+ ```
78
+
79
+ ## Tenant Data
80
+
81
+ Configure cookie conventions in the app layer:
82
+
83
+ ```ts
84
+ import { configureTenantCookie } from '@eminuckan/spine/tenant/server';
85
+
86
+ configureTenantCookie({
87
+ name: '__spine_tenant',
88
+ sameSite: 'Lax',
89
+ httpOnly: false,
90
+ });
91
+ ```
92
+
93
+ Simple apps can use the default tenant client by configuring endpoint names:
94
+
95
+ ```ts
96
+ import { configureTenantClient } from '@eminuckan/spine/tenant';
97
+
98
+ configureTenantClient({
99
+ tenantDataEndpoint: '/api/tenant/data',
100
+ tenantSwitchEndpoint: '/api/tenant/switch',
101
+ tenantCookieName: '__spine_tenant',
102
+ });
103
+ ```
104
+
105
+ Apps with different multi-tenancy contracts should provide their own client functions:
106
+
107
+ ```ts
108
+ import { configureTenantClient } from '@eminuckan/spine/tenant';
109
+
110
+ configureTenantClient({
111
+ fetchTenantData: async ({ tenantId }) => {
112
+ const response = await fetch(`/api/workspaces/${tenantId}`);
113
+ const payload = await response.json();
114
+ return {
115
+ id: payload.workspace.id,
116
+ name: payload.workspace.displayName,
117
+ plan: payload.workspace.plan,
118
+ };
119
+ },
120
+ switchTenant: async ({ tenantId }) => {
121
+ const response = await fetch('/api/session/workspace', {
122
+ method: 'PUT',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ workspaceId: tenantId }),
125
+ });
126
+
127
+ return { success: response.ok, reload: false };
128
+ },
129
+ });
130
+ ```
131
+
132
+ Use the generic client hooks for new code:
133
+
134
+ ```tsx
135
+ import { useCurrentTenant, useCurrentTenantData, useTenantData } from '@eminuckan/spine/tenant';
136
+
137
+ export function TenantBadge() {
138
+ const tenantId = useCurrentTenant();
139
+ const tenantData = useCurrentTenantData();
140
+ const { refreshTenantData } = useTenantData();
141
+
142
+ return (
143
+ <button type="button" onClick={refreshTenantData}>
144
+ {tenantData?.name ?? tenantId ?? 'No tenant'}
145
+ </button>
146
+ );
147
+ }
148
+ ```
149
+
150
+ The older organization-named hooks remain as deprecated compatibility aliases.
151
+
152
+ ## Identity Client Contracts
153
+
154
+ Simple apps can configure browser-side identity endpoints:
155
+
156
+ ```ts
157
+ import { configureIdentityStore } from '@eminuckan/spine/identity';
158
+
159
+ configureIdentityStore({
160
+ contextEndpoint: '/api/identity/context',
161
+ permissionsEndpoint: '/api/identity/permissions',
162
+ logoutPath: '/auth/logout',
163
+ });
164
+ ```
165
+
166
+ Apps with different response shapes can provide functions instead:
167
+
168
+ ```ts
169
+ configureIdentityStore({
170
+ fetchContext: async () => {
171
+ const response = await fetch('/session/me');
172
+ const payload = await response.json();
173
+
174
+ return {
175
+ userId: payload.user.id,
176
+ email: payload.user.email,
177
+ memberships: payload.accounts.map((account) => ({
178
+ tenantId: account.id,
179
+ tenantName: account.name,
180
+ })),
181
+ };
182
+ },
183
+ fetchPermissions: async () => {
184
+ const response = await fetch('/session/capabilities');
185
+ const payload = await response.json();
186
+ return payload.capabilities;
187
+ },
188
+ });
189
+ ```
190
+
191
+ ## Try the Example
192
+
193
+ ```bash
194
+ cd examples/react-router-saas
195
+ pnpm install
196
+ cp .env.example .env
197
+ pnpm dev
198
+ ```
199
+
200
+ The example uses React Router framework mode and includes auth routes, a protected
201
+ dashboard loader, provider wiring, and local app adapters for identity and tenant
202
+ fetching.
203
+
204
+ ## Validation
205
+
206
+ For library development, run:
207
+
208
+ ```bash
209
+ pnpm check
210
+ ```
211
+
212
+ `pnpm check` runs the public-surface guard, TypeScript, ESLint, Vitest, and the
213
+ package build.
@@ -0,0 +1,207 @@
1
+ # Module Reference
2
+
3
+ This is a high-level reference for Spine's public surfaces.
4
+
5
+ ## Auth
6
+
7
+ Server entry point:
8
+
9
+ - `@eminuckan/spine/auth/server`
10
+ - `@eminuckan/spine/server`
11
+ - `@eminuckan/spine/react-router/server`
12
+
13
+ Key exports:
14
+
15
+ - `login`
16
+ - `handleCallback`
17
+ - `logout`
18
+ - `getUser`
19
+ - `requireAuth`
20
+ - `getAccessToken`
21
+ - `refreshTokens`
22
+ - `getAuthSession`
23
+ - `updateAuthSession`
24
+ - `destroyAuthSession`
25
+ - `configureAuthClaimMapping`
26
+ - `resetAuthClaimMapping`
27
+ - `configureRouteProtection`
28
+ - `authRoute`
29
+ - `protectedRoute`
30
+ - `policyRoute`
31
+ - `publicRoute`
32
+
33
+ Deprecated compatibility aliases:
34
+
35
+ - `onboardingRoute`
36
+ - `subscriptionRoute`
37
+
38
+ ## Tenant
39
+
40
+ Client entry point:
41
+
42
+ - `@eminuckan/spine/tenant`
43
+
44
+ Server entry point:
45
+
46
+ - `@eminuckan/spine/tenant/server`
47
+
48
+ Key client exports:
49
+
50
+ - `TenantProvider`
51
+ - `useTenant`
52
+ - `useCurrentTenant`
53
+ - `useCurrentTenantData`
54
+ - `useTenantData`
55
+ - `useTenantStore`
56
+ - `initializeTenantStore`
57
+ - `configureTenantClient`
58
+ - `configureTenantEndpoints`
59
+ - `resetTenantClientConfig`
60
+ - `normalizeTenantDataPayload`
61
+ - `normalizeTenantSwitchPayload`
62
+ - `TenantData`
63
+ - `TenantBranding`
64
+
65
+ Deprecated compatibility aliases:
66
+
67
+ - `useCurrentOrganization`
68
+ - `useOrganization`
69
+ - `OrganizationData`
70
+ - `OrganizationBranding`
71
+
72
+ Key server exports:
73
+
74
+ - `configureTenantCookie`
75
+ - `resetTenantCookieConfig`
76
+ - `getActiveTenant`
77
+ - `setActiveTenant`
78
+ - `clearActiveTenant`
79
+ - `configureTenantResolution`
80
+ - `resetTenantResolutionConfig`
81
+ - `configureIdentityContextFetcher`
82
+ - `getCurrentTenant`
83
+ - `getAvailableTenants`
84
+ - `initializeTenant`
85
+ - `setCurrentTenant`
86
+ - `clearCurrentTenant`
87
+
88
+ ## Identity
89
+
90
+ Client entry point:
91
+
92
+ - `@eminuckan/spine/identity`
93
+
94
+ Server entry point:
95
+
96
+ - `@eminuckan/spine/identity/server`
97
+
98
+ Key client exports:
99
+
100
+ - `IdentityContextProvider`
101
+ - `useIdentityStore`
102
+ - `useIdentityContext`
103
+ - `useIsOnboarded`
104
+ - `useHasSubscription`
105
+ - `useUserTenants`
106
+ - `useUserPermissions`
107
+ - `configureIdentityStore`
108
+ - `configureIdentityEndpoints`
109
+ - `resetIdentityStoreConfig`
110
+ - `normalizeIdentityContextPayload`
111
+ - `normalizeIdentityPermissionsPayload`
112
+
113
+ Key server exports:
114
+
115
+ - `configureIdentityAPIFetcher`
116
+ - `configurePermissionFetcher`
117
+ - `fetchIdentityContext`
118
+ - `getIdentityContext`
119
+ - `fetchUserPermissions`
120
+ - `contextToUserInfo`
121
+ - `clearIdentityContextCache`
122
+ - `hasContextVersionChanged`
123
+ - `updateContextVersion`
124
+
125
+ ## Permissions
126
+
127
+ Client entry point:
128
+
129
+ - `@eminuckan/spine/permissions`
130
+
131
+ Server entry point:
132
+
133
+ - `@eminuckan/spine/server`
134
+
135
+ Key client exports:
136
+
137
+ - `usePermissionStore`
138
+ - `initializePermissions`
139
+ - `getPermissionChecker`
140
+ - `getPermissions`
141
+ - `usePermission`
142
+ - `useHasPermission`
143
+ - `usePermissions`
144
+ - `useHasPermissions`
145
+ - `useModulePermission`
146
+ - `useHasModulePermission`
147
+ - `usePermissionChecker`
148
+ - `useAllPermissions`
149
+ - `ProtectedButton`
150
+ - `ProtectedLink`
151
+ - `ProtectedSection`
152
+ - `ProtectedAction`
153
+
154
+ Key server exports:
155
+
156
+ - `configurePermissionRouteProtection`
157
+ - `resetPermissionRouteProtection`
158
+ - `requirePermission`
159
+ - `checkPermission`
160
+ - `withPermission`
161
+
162
+ ## API Client
163
+
164
+ Client entry point:
165
+
166
+ - `@eminuckan/spine/api-client`
167
+
168
+ Server entry point:
169
+
170
+ - `@eminuckan/spine/api-client/server`
171
+
172
+ Key server exports:
173
+
174
+ - `createAPIConfigFactory`
175
+ - `createFetchMiddleware`
176
+ - `createApiClient`
177
+ - `APIConfigFactoryOptions`
178
+ - `APIHeaderStrategyContext`
179
+
180
+ ## Query Client
181
+
182
+ Entry point:
183
+
184
+ - `@eminuckan/spine/query-client`
185
+
186
+ Key exports:
187
+
188
+ - `createQueryClient`
189
+ - `cachePresets`
190
+ - `createQueryKeyFactory`
191
+ - `invalidationHelpers`
192
+
193
+ ## Logging
194
+
195
+ Entry point:
196
+
197
+ - `@eminuckan/spine/logging`
198
+
199
+ The logging module provides typed logger configuration and a default service-level logger.
200
+
201
+ ## SignalR
202
+
203
+ Entry point:
204
+
205
+ - `@eminuckan/spine/signalr`
206
+
207
+ Use this module for generic realtime client helpers and types.
@@ -0,0 +1,92 @@
1
+ # Releasing Spine
2
+
3
+ This guide covers local validation, GitHub release setup, and npm publishing.
4
+
5
+ ## Before Releasing
6
+
7
+ Run:
8
+
9
+ ```bash
10
+ pnpm install
11
+ pnpm check
12
+ npm pack --dry-run
13
+ ```
14
+
15
+ Review:
16
+
17
+ - `README.md`
18
+ - `package.json`
19
+ - `CHANGELOG.md`
20
+ - `README.md`
21
+ - `ROADMAP.md`
22
+
23
+ ## Versioning
24
+
25
+ Update the version in `package.json` and add a changelog entry.
26
+
27
+ ## GitHub Repository
28
+
29
+ If the repository does not exist yet:
30
+
31
+ ```bash
32
+ gh repo create eminuckan/spine --public --source=. --remote=origin --push
33
+ ```
34
+
35
+ Useful follow-up commands:
36
+
37
+ ```bash
38
+ gh repo edit eminuckan/spine --description "Framework-agnostic SaaS primitives for auth, identity, multi-tenancy, permissions, and API access."
39
+ gh repo edit eminuckan/spine --homepage "https://github.com/eminuckan/spine#readme"
40
+ ```
41
+
42
+ ## npm Publishing
43
+
44
+ You need npm authentication on the machine for manual publishing, or npm trusted publishing configured for GitHub Actions.
45
+
46
+ Manual publish:
47
+
48
+ ```bash
49
+ npm publish --access public
50
+ ```
51
+
52
+ Dry run:
53
+
54
+ ```bash
55
+ npm publish --dry-run
56
+ ```
57
+
58
+ ## GitHub Actions Release Workflow
59
+
60
+ The repository includes a release workflow that expects npm trusted publishing to be configured for the `npm-publish` GitHub environment.
61
+
62
+ Recommended tag format:
63
+
64
+ ```bash
65
+ git tag v0.2.0
66
+ git push origin v0.2.0
67
+ ```
68
+
69
+ ## Release Checklist
70
+
71
+ - Version updated
72
+ - Changelog updated
73
+ - Docs updated
74
+ - `pnpm check` passes
75
+ - `npm pack --dry-run` includes only intended files
76
+ - GitHub repo is public
77
+ - npm auth is available
78
+ - Publish succeeds
79
+
80
+ ## Troubleshooting
81
+
82
+ ### `npm whoami` fails
83
+
84
+ You are not authenticated with npm on the current machine.
85
+
86
+ ### `npm view @eminuckan/spine version` returns 404
87
+
88
+ The package is not currently published or the scope/package name is unavailable to the current user.
89
+
90
+ ### GitHub push fails because the repo does not exist
91
+
92
+ Create the repository first with `gh repo create`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eminuckan/spine",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
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",
@@ -80,8 +80,14 @@
80
80
  },
81
81
  "files": [
82
82
  "dist",
83
+ "docs",
83
84
  "CHANGELOG.md",
85
+ "CODE_OF_CONDUCT.md",
86
+ "CONTRIBUTING.md",
84
87
  "README.md",
88
+ "MAINTAINERS.md",
89
+ "ROADMAP.md",
90
+ "SECURITY.md",
85
91
  "LICENSE"
86
92
  ],
87
93
  "scripts": {
@@ -89,12 +95,23 @@
89
95
  "dev": "tsdown --watch",
90
96
  "typecheck": "tsc --noEmit",
91
97
  "lint": "eslint src --max-warnings 0",
92
- "prepublishOnly": "pnpm run build"
98
+ "test": "vitest run",
99
+ "check:public-surface": "node scripts/check-public-surface.mjs",
100
+ "check": "pnpm run check:public-surface && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
101
+ "prepublishOnly": "pnpm run check"
93
102
  },
94
103
  "peerDependencies": {
95
104
  "@tanstack/react-query": ">=5.0.0",
96
105
  "react": ">=18.0.0"
97
106
  },
107
+ "peerDependenciesMeta": {
108
+ "@tanstack/react-query": {
109
+ "optional": true
110
+ },
111
+ "react": {
112
+ "optional": true
113
+ }
114
+ },
98
115
  "dependencies": {
99
116
  "@microsoft/signalr": "^9.0.0",
100
117
  "ioredis": "^5.4.1",
@@ -110,7 +127,8 @@
110
127
  "globals": "^17.4.0",
111
128
  "tsdown": "^0.9.0",
112
129
  "typescript": "^5.7.0",
113
- "typescript-eslint": "^8.57.0"
130
+ "typescript-eslint": "^8.57.0",
131
+ "vitest": "^4.1.8"
114
132
  },
115
133
  "engines": {
116
134
  "node": ">=20.0.0"
@@ -1 +0,0 @@
1
- {"version":3,"file":"access-DlAqsVGQ.js","names":["EMPTY_DECISION: AccessDecision","value: string","subscription?: SubscriptionAccessSnapshot | null","grantedFeatures: readonly string[]","requiredFeatures: readonly string[]","operator: AccessOperator","requirement: AccessRequirement | null | undefined","subject: AccessSubject","reasons: AccessDeniedReason[]","missingPermissions: string[]","missingFeatures: string[]"],"sources":["../src/access/evaluator.ts"],"sourcesContent":["import { PermissionChecker } from '../permissions/permission-service';\nimport type {\n AccessDecision,\n AccessDeniedReason,\n AccessOperator,\n AccessRequirement,\n AccessSubject,\n SubscriptionAccessSnapshot,\n} from './types';\n\nconst ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing']);\n\nconst EMPTY_DECISION: AccessDecision = Object.freeze({\n allowed: true,\n reasons: [],\n missingPermissions: [],\n missingFeatures: [],\n});\n\nfunction normalizeFeature(value: string): string {\n return value.trim().toLowerCase();\n}\n\nfunction hasActiveSubscription(subscription?: SubscriptionAccessSnapshot | null): boolean {\n if (!subscription?.exists) {\n return false;\n }\n\n return ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status?.toLowerCase() ?? '');\n}\n\nfunction checkFeatureSet(\n grantedFeatures: readonly string[],\n requiredFeatures: readonly string[],\n operator: AccessOperator,\n): string[] {\n if (requiredFeatures.length === 0) {\n return [];\n }\n\n const granted = new Set(grantedFeatures.map(normalizeFeature));\n const normalizedRequired = requiredFeatures.map(normalizeFeature).filter(Boolean);\n\n if (operator === 'OR') {\n return normalizedRequired.some((feature) => granted.has(feature)) ? [] : normalizedRequired;\n }\n\n return normalizedRequired.filter((feature) => !granted.has(feature));\n}\n\nexport function evaluateAccessRequirement(\n requirement: AccessRequirement | null | undefined,\n subject: AccessSubject,\n): AccessDecision {\n if (!requirement) {\n return EMPTY_DECISION;\n }\n\n const reasons: AccessDeniedReason[] = [];\n const permissions = requirement.permissions ?? [];\n const features = requirement.features ?? [];\n let missingPermissions: string[] = [];\n let missingFeatures: string[] = [];\n\n if (requirement.internalOnly && !subject.isInternalUser) {\n reasons.push('internal-only');\n }\n\n if (requirement.requireActiveSubscription && !hasActiveSubscription(subject.subscription)) {\n reasons.push('inactive-subscription');\n }\n\n if (permissions.length > 0) {\n const checker = new PermissionChecker([...(subject.permissions ?? [])]);\n const result = checker.hasPermissions([...permissions], {\n operator: requirement.permissionOperator ?? 'AND',\n wildcard: true,\n });\n\n if (!result.hasPermission) {\n missingPermissions = result.missingPermissions;\n reasons.push('missing-permissions');\n }\n }\n\n if (features.length > 0) {\n missingFeatures = checkFeatureSet(\n subject.subscription?.featureKeys ?? [],\n features,\n requirement.featureOperator ?? 'AND',\n );\n\n if (missingFeatures.length > 0) {\n reasons.push('missing-features');\n }\n }\n\n return {\n allowed: reasons.length === 0,\n reasons,\n missingPermissions,\n missingFeatures,\n };\n}\n"],"mappings":";;;AAUA,MAAM,+BAA+B,IAAI,IAAI,CAAC,UAAU,UAAW;AAEnE,MAAMA,iBAAiC,OAAO,OAAO;CACnD,SAAS;CACT,SAAS,CAAE;CACX,oBAAoB,CAAE;CACtB,iBAAiB,CAAE;AACpB,EAAC;AAEF,SAAS,iBAAiBC,OAAuB;AAC/C,QAAO,MAAM,MAAM,CAAC,aAAa;AAClC;AAED,SAAS,sBAAsBC,cAA2D;AACxF,MAAK,cAAc,OACjB,QAAO;AAGT,QAAO,6BAA6B,IAAI,aAAa,QAAQ,aAAa,IAAI,GAAG;AAClF;AAED,SAAS,gBACPC,iBACAC,kBACAC,UACU;AACV,KAAI,iBAAiB,WAAW,EAC9B,QAAO,CAAE;CAGX,MAAM,UAAU,IAAI,IAAI,gBAAgB,IAAI,iBAAiB;CAC7D,MAAM,qBAAqB,iBAAiB,IAAI,iBAAiB,CAAC,OAAO,QAAQ;AAEjF,KAAI,aAAa,KACf,QAAO,mBAAmB,KAAK,CAAC,YAAY,QAAQ,IAAI,QAAQ,CAAC,GAAG,CAAE,IAAG;AAG3E,QAAO,mBAAmB,OAAO,CAAC,aAAa,QAAQ,IAAI,QAAQ,CAAC;AACrE;AAED,SAAgB,0BACdC,aACAC,SACgB;AAChB,MAAK,YACH,QAAO;CAGT,MAAMC,UAAgC,CAAE;CACxC,MAAM,cAAc,YAAY,eAAe,CAAE;CACjD,MAAM,WAAW,YAAY,YAAY,CAAE;CAC3C,IAAIC,qBAA+B,CAAE;CACrC,IAAIC,kBAA4B,CAAE;AAElC,KAAI,YAAY,iBAAiB,QAAQ,eACvC,SAAQ,KAAK,gBAAgB;AAG/B,KAAI,YAAY,8BAA8B,sBAAsB,QAAQ,aAAa,CACvF,SAAQ,KAAK,wBAAwB;AAGvC,KAAI,YAAY,SAAS,GAAG;EAC1B,MAAM,UAAU,IAAI,kBAAkB,CAAC,GAAI,QAAQ,eAAe,CAAE,CAAE;EACtE,MAAM,SAAS,QAAQ,eAAe,CAAC,GAAG,WAAY,GAAE;GACtD,UAAU,YAAY,sBAAsB;GAC5C,UAAU;EACX,EAAC;AAEF,OAAK,OAAO,eAAe;AACzB,wBAAqB,OAAO;AAC5B,WAAQ,KAAK,sBAAsB;EACpC;CACF;AAED,KAAI,SAAS,SAAS,GAAG;AACvB,oBAAkB,gBAChB,QAAQ,cAAc,eAAe,CAAE,GACvC,UACA,YAAY,mBAAmB,MAChC;AAED,MAAI,gBAAgB,SAAS,EAC3B,SAAQ,KAAK,mBAAmB;CAEnC;AAED,QAAO;EACL,SAAS,QAAQ,WAAW;EAC5B;EACA;EACA;CACD;AACF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"api-client-CZapXIF8.js","names":["problemDetails: ProblemDetails","response: Response","config: ErrorHandlerConfig","error: ApiError","message: string","type: 'error' | 'success' | 'info'","DEFAULT_RETRY_CONFIG: Required<RetryConfig>","error: unknown","attempt: number","baseDelay: number","maxDelay: number","fn: () => Promise<T>","config?: RetryConfig","lastError: unknown","input: RequestInfo | URL","init?: RequestInit & { timeout?: number }","init?: RequestInit"],"sources":["../src/api-client/error-codes.ts","../src/api-client/error-handler.ts","../src/api-client/retry-handler.ts"],"sourcesContent":["/**\n * Error Codes\n * \n * Standard error codes from backend.\n */\n\nexport const ErrorCodes = {\n // Validation\n Validation: {\n Failed: 'ValidationFailed',\n },\n\n // Identity\n Identity: {\n InvalidCredentials: 'InvalidCredentials',\n UserInactive: 'UserInactive',\n TenantContextMissing: 'TenantContextMissing',\n InvalidCurrentPassword: 'InvalidCurrentPassword',\n OwnerCannotDisableMembership: 'OwnerCannotDisableMembership',\n CurrentUserMissing: 'CurrentUserMissing',\n UserNotFound: 'UserNotFound',\n EmailAlreadyExists: 'EmailAlreadyExists',\n },\n\n // Accounting\n Accounting: {\n OpeningBalanceLockedReconciliation: 'OpeningBalanceLockedReconciliation',\n OpeningBalanceLockedJournal: 'OpeningBalanceLockedJournal',\n SystemLocked: 'SystemLocked',\n AccountsDuplicate: 'AccountsDuplicate',\n AccountNotFound: 'AccountNotFound',\n },\n\n // Subscription\n Subscription: {\n SubscriptionRequired: 'SubscriptionRequired',\n SubscriptionExpired: 'SubscriptionExpired',\n FeatureNotAvailable: 'FeatureNotAvailable',\n },\n\n // Property Management\n PropertyManagement: {\n PropertyNotFound: 'PropertyNotFound',\n UnitNotFound: 'UnitNotFound',\n DuplicateProperty: 'DuplicateProperty',\n DuplicateUnit: 'DuplicateUnit',\n },\n\n // Leasing\n Leasing: {\n LeaseNotFound: 'LeaseNotFound',\n LeaseConflict: 'LeaseConflict',\n TenantNotFound: 'TenantNotFound',\n LateFeeValidationError: 'LateFeeValidationError',\n },\n\n // General\n NotFound: 'NotFound',\n Unauthorized: 'Unauthorized',\n Forbidden: 'Forbidden',\n InternalError: 'InternalError',\n} as const;\n\n/**\n * Check if error code is from PropertyManagement module\n */\nexport function isPropertyManagementError(code: string): boolean {\n return Object.values(ErrorCodes.PropertyManagement).includes(code as any);\n}\n\n/**\n * Check if error code is from Leasing module\n */\nexport function isLeasingError(code: string): boolean {\n return Object.values(ErrorCodes.Leasing).includes(code as any);\n}\n","/**\n * Error Handler\n * \n * Centralized error handling with ProblemDetails parsing.\n */\n\nimport type { ProblemDetails, ValidationError } from './types';\nimport { ErrorCodes } from './error-codes';\nimport { logger } from '../logging';\n\n/**\n * API Error Class\n */\nexport class ApiError extends Error {\n constructor(\n public problemDetails: ProblemDetails,\n public response: Response\n ) {\n super(problemDetails.detail);\n this.name = 'ApiError';\n }\n\n get code(): string {\n return this.problemDetails.title;\n }\n\n get status(): number {\n return this.problemDetails.status;\n }\n\n get isValidationError(): boolean {\n return this.code === ErrorCodes.Validation.Failed;\n }\n\n get validationErrors(): ValidationError[] {\n return this.problemDetails.meta?.errors ?? [];\n }\n\n get traceId(): string {\n return this.problemDetails.traceId;\n }\n}\n\n/**\n * Parse API error response\n */\nexport async function handleApiError(response: Response): Promise<never> {\n let problemDetails: ProblemDetails;\n\n try {\n problemDetails = await response.json();\n } catch {\n problemDetails = {\n type: `https://httpstatuses.com/${response.status}`,\n title: 'UnknownError',\n status: response.status,\n detail: response.statusText || 'An unknown error occurred',\n traceId: response.headers.get('x-trace-id') ?? 'unknown',\n };\n }\n\n logger.error('API request failed', undefined, {\n code: problemDetails.title,\n status: problemDetails.status,\n detail: problemDetails.detail,\n traceId: problemDetails.traceId,\n });\n\n throw new ApiError(problemDetails, response);\n}\n\n/**\n * Error Handler Configuration\n */\nexport interface ErrorHandlerConfig {\n handlers?: Partial<Record<string, (error: ApiError) => void>>;\n showToast?: boolean;\n toastMessages?: Partial<Record<string, string>>;\n onError?: (error: ApiError) => void;\n toast?: (message: string, type: 'error' | 'success' | 'info') => void;\n}\n\n/**\n * Error Handler Class\n */\nexport class ErrorHandler {\n private config: ErrorHandlerConfig;\n\n constructor(config: ErrorHandlerConfig = {}) {\n this.config = {\n showToast: true,\n ...config,\n };\n }\n\n handle(error: ApiError): void {\n const code = error.code;\n\n logger.error('Handling API Error', undefined, {\n code,\n status: error.status,\n traceId: error.traceId,\n });\n\n // Try custom handler first\n if (this.config.handlers?.[code]) {\n this.config.handlers[code]!(error);\n return;\n }\n\n // Fallback\n if (this.config.onError) {\n this.config.onError(error);\n } else {\n this.showDefaultToast(error);\n }\n }\n\n private showToast(message: string, type: 'error' | 'success' | 'info'): void {\n if (this.config.showToast && this.config.toast) {\n this.config.toast(message, type);\n }\n }\n\n private showDefaultToast(error: ApiError): void {\n const message =\n this.config.toastMessages?.[error.code] ||\n error.problemDetails.detail ||\n 'An error occurred';\n this.showToast(message, 'error');\n }\n}\n\n/**\n * Create global error handler\n */\nexport function createErrorHandler(config?: ErrorHandlerConfig): ErrorHandler {\n return new ErrorHandler(config);\n}\n\n/**\n * Map validation errors to form fields\n */\nexport function mapValidationErrors(errors: ValidationError[]): Record<string, string> {\n const mapped: Record<string, string> = {};\n for (const error of errors) {\n const fieldName = error.field.charAt(0).toLowerCase() + error.field.slice(1);\n mapped[fieldName] = error.message;\n }\n return mapped;\n}\n","/**\n * Retry Handler\n * \n * Retry logic for API requests.\n */\n\nimport type { RetryConfig } from './types';\n\nconst DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {\n maxRetries: 3,\n baseDelay: 1000,\n maxDelay: 10000,\n retryCondition: (error: unknown) => {\n if (error instanceof Response) {\n return error.status >= 500 || error.status === 429;\n }\n return false;\n },\n};\n\n/**\n * Calculate backoff delay\n */\nexport function calculateBackoffDelay(\n attempt: number,\n baseDelay: number,\n maxDelay: number\n): number {\n const delay = baseDelay * Math.pow(2, attempt);\n const jitter = Math.random() * 0.3 * delay;\n return Math.min(delay + jitter, maxDelay);\n}\n\n/**\n * Check if error is retryable\n */\nexport function isRetryableError(error: unknown): boolean {\n if (error instanceof Response) {\n return error.status >= 500 || error.status === 429;\n }\n if (error instanceof TypeError) {\n // Network errors\n return true;\n }\n return false;\n}\n\n/**\n * Execute function with retry logic\n */\nexport async function withRetry<T>(\n fn: () => Promise<T>,\n config?: RetryConfig\n): Promise<T> {\n const { maxRetries, baseDelay, maxDelay, retryCondition } = {\n ...DEFAULT_RETRY_CONFIG,\n ...config,\n };\n\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n return await fn();\n } catch (error) {\n lastError = error;\n\n if (attempt === maxRetries || !retryCondition(error)) {\n throw error;\n }\n\n const delay = calculateBackoffDelay(attempt, baseDelay, maxDelay);\n console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms`);\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n }\n\n throw lastError;\n}\n\n/**\n * Fetch with timeout\n */\nexport async function fetchWithTimeout(\n input: RequestInfo | URL,\n init?: RequestInit & { timeout?: number }\n): Promise<Response> {\n const { timeout = 30000, ...fetchInit } = init || {};\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n try {\n const response = await fetch(input, {\n ...fetchInit,\n signal: controller.signal,\n });\n return response;\n } finally {\n clearTimeout(timeoutId);\n }\n}\n\n/**\n * Create fetch function with retry\n */\nexport function createRetryFetch(config?: RetryConfig) {\n return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {\n return withRetry(() => fetch(input, init), config);\n };\n}\n"],"mappings":"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"}