@selvajs/server 0.2.0-beta.2 → 0.2.0-beta.4
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/LICENSE +1 -1
- package/README.md +32 -0
- package/dist/compute/client-cache.d.ts +12 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +18 -3
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/definition-byte-cache.d.ts +85 -0
- package/dist/compute/definition-byte-cache.d.ts.map +1 -0
- package/dist/compute/definition-byte-cache.js +110 -0
- package/dist/compute/definition-byte-cache.js.map +1 -0
- package/dist/compute/index.d.ts +7 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +6 -1
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +52 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +33 -1
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/memory-solve-cache.d.ts +58 -0
- package/dist/compute/memory-solve-cache.d.ts.map +1 -0
- package/dist/compute/memory-solve-cache.js +0 -0
- package/dist/compute/memory-solve-cache.js.map +1 -0
- package/dist/compute/safe-url.d.ts.map +1 -1
- package/dist/compute/safe-url.js +16 -0
- package/dist/compute/safe-url.js.map +1 -1
- package/dist/compute/solve-cache-envelope.d.ts +53 -0
- package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
- package/dist/compute/solve-cache-envelope.js +66 -0
- package/dist/compute/solve-cache-envelope.js.map +1 -0
- package/dist/compute/solve-cache-key.d.ts +65 -0
- package/dist/compute/solve-cache-key.d.ts.map +1 -0
- package/dist/compute/solve-cache-key.js +50 -0
- package/dist/compute/solve-cache-key.js.map +1 -0
- package/dist/compute/solve-cache-single-flight.d.ts +42 -0
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
- package/dist/compute/solve-cache-single-flight.js +50 -0
- package/dist/compute/solve-cache-single-flight.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +71 -8
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +180 -8
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/definitions/definition-service.d.ts +1 -1
- package/dist/definitions/definition-service.d.ts.map +1 -1
- package/dist/definitions/definition-service.js +8 -4
- package/dist/definitions/definition-service.js.map +1 -1
- package/package.json +14 -18
- package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
- package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
- package/dist/access/__tests__/project-access-input.test.js +0 -146
- package/dist/access/__tests__/project-access-input.test.js.map +0 -1
- package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
- package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/client-cache.test.js +0 -200
- package/dist/compute/__tests__/client-cache.test.js.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.js +0 -98
- package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.js +0 -114
- package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
- package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
- package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
- package/dist/compute/__tests__/safe-url.test.js +0 -71
- package/dist/compute/__tests__/safe-url.test.js.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +0 -292
- package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
- package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
- package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
- package/dist/compute/__tests__/transform-input.test.js +0 -120
- package/dist/compute/__tests__/transform-input.test.js.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.js +0 -196
- package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
- package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
- package/dist/http/__tests__/body-size.test.d.ts +0 -2
- package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
- package/dist/http/__tests__/body-size.test.js +0 -24
- package/dist/http/__tests__/body-size.test.js.map +0 -1
- package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
- package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
- package/dist/http/__tests__/route-classifier.test.js +0 -54
- package/dist/http/__tests__/route-classifier.test.js.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.js +0 -44
- package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
- package/dist/http/__tests__/security-headers.test.d.ts +0 -2
- package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
- package/dist/http/__tests__/security-headers.test.js +0 -24
- package/dist/http/__tests__/security-headers.test.js.map +0 -1
- package/dist/ops/__tests__/semver.test.d.ts +0 -2
- package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
- package/dist/ops/__tests__/semver.test.js +0 -38
- package/dist/ops/__tests__/semver.test.js.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
- package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.js +0 -50
- package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
- package/src/access/__tests__/project-access-input.test.ts +0 -175
- package/src/access/index.ts +0 -9
- package/src/access/project-access-input.ts +0 -145
- package/src/compute/__tests__/client-cache.test.ts +0 -224
- package/src/compute/__tests__/rate-limit.test.ts +0 -106
- package/src/compute/__tests__/remote-definition.test.ts +0 -140
- package/src/compute/__tests__/safe-url.test.ts +0 -72
- package/src/compute/__tests__/solve-pipeline.test.ts +0 -341
- package/src/compute/__tests__/transform-input.test.ts +0 -162
- package/src/compute/client-cache.ts +0 -300
- package/src/compute/index.ts +0 -48
- package/src/compute/limits.ts +0 -159
- package/src/compute/rate-limit.ts +0 -110
- package/src/compute/remote-definition.ts +0 -149
- package/src/compute/safe-url.ts +0 -186
- package/src/compute/solve-pipeline.ts +0 -339
- package/src/compute/transform-input.ts +0 -66
- package/src/definitions/__tests__/load-for-render.test.ts +0 -244
- package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
- package/src/definitions/definition-service.ts +0 -243
- package/src/definitions/index.ts +0 -22
- package/src/definitions/load-for-render.ts +0 -272
- package/src/definitions/schema-extraction.ts +0 -117
- package/src/errors/SentryErrorReporter.ts +0 -88
- package/src/errors/index.ts +0 -3
- package/src/http/__tests__/body-size.test.ts +0 -27
- package/src/http/__tests__/route-classifier.test.ts +0 -60
- package/src/http/__tests__/safe-redirect.test.ts +0 -47
- package/src/http/__tests__/security-headers.test.ts +0 -29
- package/src/http/body-size.ts +0 -24
- package/src/http/index.ts +0 -10
- package/src/http/redirect.ts +0 -16
- package/src/http/route-classifier.ts +0 -74
- package/src/http/security-headers.ts +0 -42
- package/src/index.ts +0 -27
- package/src/ops/__tests__/semver.test.ts +0 -42
- package/src/ops/index.ts +0 -3
- package/src/ops/semver.ts +0 -60
- package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
- package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
- package/src/providers/create-selva-providers.ts +0 -241
- package/src/providers/index.ts +0 -12
- package/src/tokens/__tests__/token-codec.test.ts +0 -63
- package/src/tokens/index.ts +0 -8
- package/src/tokens/token-codec.ts +0 -86
package/src/http/body-size.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Minimal read-side view of a request's headers — `Headers` satisfies it. */
|
|
2
|
-
export interface HeadersLike {
|
|
3
|
-
get(name: string): string | null;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* True when the request's declared `Content-Length` exceeds `maxBytes`.
|
|
8
|
-
* Check BEFORE the body is read so a malicious client can't burn memory by
|
|
9
|
-
* sending a huge payload to a small-body endpoint; the route maps a `true`
|
|
10
|
-
* result to its transport's 413.
|
|
11
|
-
*
|
|
12
|
-
* Background: a global body-size limit (e.g. adapter-node's `BODY_SIZE_LIMIT`)
|
|
13
|
-
* has to be set high enough for the largest legitimate upload, which means
|
|
14
|
-
* smaller JSON endpoints inherit that ceiling by default. This check is the
|
|
15
|
-
* per-route lower bound.
|
|
16
|
-
*
|
|
17
|
-
* Caveat: requests without `Content-Length` (chunked transfer encoding) bypass
|
|
18
|
-
* this check. Most browsers and HTTP clients send Content-Length on POST/PUT.
|
|
19
|
-
* The global limit is the backstop for those.
|
|
20
|
-
*/
|
|
21
|
-
export function declaredBodySizeExceeds(headers: HeadersLike, maxBytes: number): boolean {
|
|
22
|
-
const declared = Number(headers.get('content-length'));
|
|
23
|
-
return Number.isFinite(declared) && declared > maxBytes;
|
|
24
|
-
}
|
package/src/http/index.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// HTTP hardening helpers — transport-agnostic request/response guards.
|
|
2
|
-
|
|
3
|
-
export { safeRedirectTarget } from './redirect.js';
|
|
4
|
-
export { declaredBodySizeExceeds, type HeadersLike } from './body-size.js';
|
|
5
|
-
export { applySecurityHeaders, type SecurityHeaderOptions } from './security-headers.js';
|
|
6
|
-
export {
|
|
7
|
-
createRouteClassifier,
|
|
8
|
-
type RouteClassifier,
|
|
9
|
-
type RouteClassifierConfig
|
|
10
|
-
} from './route-classifier.js';
|
package/src/http/redirect.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validate a user-supplied post-login redirect target. Accepts only same-origin
|
|
3
|
-
* relative paths starting with `/` followed by a non-`/` character, so
|
|
4
|
-
* `//evil.com/path` (protocol-relative URL — browser treats as cross-origin)
|
|
5
|
-
* and `/\evil.com` (back-slash variants some browsers normalize) are rejected.
|
|
6
|
-
*
|
|
7
|
-
* Always returns a safe path: the validated target on success, the fallback
|
|
8
|
-
* otherwise. Routes call this with `redirectTo` from form data or query string.
|
|
9
|
-
*/
|
|
10
|
-
export function safeRedirectTarget(raw: string | null | undefined, fallback: string): string {
|
|
11
|
-
if (typeof raw !== 'string' || raw.length < 2) return fallback;
|
|
12
|
-
if (raw[0] !== '/') return fallback;
|
|
13
|
-
// Reject protocol-relative (`//host`) and back-slash bypass (`/\host`).
|
|
14
|
-
if (raw[1] === '/' || raw[1] === '\\') return fallback;
|
|
15
|
-
return raw;
|
|
16
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deny-by-default route classification. Every request is gated unless its
|
|
3
|
-
* path matches one of the explicit allowlists supplied here — so adding a new
|
|
4
|
-
* top-level route is safe: forgetting to update the lists makes it gated (a
|
|
5
|
-
* loud 401/redirect), not silently public.
|
|
6
|
-
*
|
|
7
|
-
* The *pattern* is reusable; the *values* are app policy — a consuming app
|
|
8
|
-
* supplies its own route sets. All matching is plain exact/prefix string
|
|
9
|
-
* comparison: cheap enough for the hooks hot path, and no regex to get wrong.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export interface RouteClassifierConfig {
|
|
13
|
-
/** Exact-match public pages — no session needed (e.g. `/`, `/login`). */
|
|
14
|
-
publicPages?: Iterable<string>;
|
|
15
|
-
/**
|
|
16
|
-
* Public path prefixes (e.g. `/auth/` for OAuth start + callback — the
|
|
17
|
-
* user has no session yet by definition).
|
|
18
|
-
*/
|
|
19
|
-
publicPrefixes?: readonly string[];
|
|
20
|
-
/**
|
|
21
|
-
* Exact-match API endpoints that must answer without a session (e.g. a
|
|
22
|
-
* load-balancer health probe). Anything added here must be safe to expose
|
|
23
|
-
* to anonymous callers.
|
|
24
|
-
*/
|
|
25
|
-
publicApis?: Iterable<string>;
|
|
26
|
-
/**
|
|
27
|
-
* A single prefix whose routes apply their own per-request authorization
|
|
28
|
-
* (e.g. a blob proxy classifying each asset path). The hook must NOT deny
|
|
29
|
-
* these up front; they classify as public (best-effort session attach) and
|
|
30
|
-
* the route makes the real decision. Keep this to ONE prefix — every other
|
|
31
|
-
* API route stays deny-by-default.
|
|
32
|
-
*/
|
|
33
|
-
selfGatingPrefix?: string;
|
|
34
|
-
/** Static-asset path prefixes the adapter serves directly (e.g. `/_app/`). */
|
|
35
|
-
staticPrefixes?: readonly string[];
|
|
36
|
-
/** Exact static-asset paths (e.g. `/favicon.svg`, `/robots.txt`). */
|
|
37
|
-
staticPaths?: Iterable<string>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface RouteClassifier {
|
|
41
|
-
/** Static assets bypass auth gating; callers may add cache headers. */
|
|
42
|
-
isStaticAsset(pathname: string): boolean;
|
|
43
|
-
/** True for paths under `selfGatingPrefix` — the route gates itself. */
|
|
44
|
-
isSelfGatingApiRoute(pathname: string): boolean;
|
|
45
|
-
/** True when the path needs no session: pages, APIs, prefixes, self-gating. */
|
|
46
|
-
isPublicRoute(pathname: string): boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createRouteClassifier(config: RouteClassifierConfig): RouteClassifier {
|
|
50
|
-
const publicPages = new Set(config.publicPages ?? []);
|
|
51
|
-
const publicPrefixes = config.publicPrefixes ?? [];
|
|
52
|
-
const publicApis = new Set(config.publicApis ?? []);
|
|
53
|
-
const selfGatingPrefix = config.selfGatingPrefix;
|
|
54
|
-
const staticPrefixes = config.staticPrefixes ?? [];
|
|
55
|
-
const staticPaths = new Set(config.staticPaths ?? []);
|
|
56
|
-
|
|
57
|
-
function isStaticAsset(pathname: string): boolean {
|
|
58
|
-
if (staticPaths.has(pathname)) return true;
|
|
59
|
-
return staticPrefixes.some((p) => pathname.startsWith(p));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isSelfGatingApiRoute(pathname: string): boolean {
|
|
63
|
-
return selfGatingPrefix !== undefined && pathname.startsWith(selfGatingPrefix);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function isPublicRoute(pathname: string): boolean {
|
|
67
|
-
if (publicPages.has(pathname)) return true;
|
|
68
|
-
if (publicApis.has(pathname)) return true;
|
|
69
|
-
if (isSelfGatingApiRoute(pathname)) return true;
|
|
70
|
-
return publicPrefixes.some((p) => pathname.startsWith(p));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return { isStaticAsset, isSelfGatingApiRoute, isPublicRoute };
|
|
74
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export interface SecurityHeaderOptions {
|
|
2
|
-
/**
|
|
3
|
-
* Set `Strict-Transport-Security` — production only. Locking dev
|
|
4
|
-
* (http://localhost) into HTTPS would brick local development, so the
|
|
5
|
-
* caller decides what "production" means (e.g. `NODE_ENV`).
|
|
6
|
-
*/
|
|
7
|
-
hsts: boolean;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Apply baseline browser-hardening headers to a response. Cheap wins that
|
|
12
|
-
* don't require UI verification:
|
|
13
|
-
*
|
|
14
|
-
* - X-Content-Type-Options: nosniff — disables MIME sniffing.
|
|
15
|
-
* - Referrer-Policy: strict-origin-when-cross-origin — strips the path
|
|
16
|
-
* and query from cross-origin Referer headers; matches modern browser
|
|
17
|
-
* defaults but pins the behavior in case the default ever drifts.
|
|
18
|
-
* - Permissions-Policy: opt out of browser APIs Selva-engine apps don't
|
|
19
|
-
* use, so XSS can't enable them.
|
|
20
|
-
* - HSTS when `opts.hsts` (production).
|
|
21
|
-
*
|
|
22
|
-
* **Intentionally NOT set here:**
|
|
23
|
-
* - Content-Security-Policy + frame-ancestors. Selva-engine apps are built
|
|
24
|
-
* for iframe embedding, so a strict CSP needs UI-phase validation against
|
|
25
|
-
* real consumer sites before it can ship.
|
|
26
|
-
* - X-Frame-Options. Same iframe-embedding constraint.
|
|
27
|
-
* - Cache-Control. Asset-path layout is app policy; set it in the caller.
|
|
28
|
-
*
|
|
29
|
-
* Returns the same response for chaining.
|
|
30
|
-
*/
|
|
31
|
-
export function applySecurityHeaders(response: Response, opts: SecurityHeaderOptions): Response {
|
|
32
|
-
response.headers.set('X-Content-Type-Options', 'nosniff');
|
|
33
|
-
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
34
|
-
response.headers.set(
|
|
35
|
-
'Permissions-Policy',
|
|
36
|
-
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
|
|
37
|
-
);
|
|
38
|
-
if (opts.hsts) {
|
|
39
|
-
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
40
|
-
}
|
|
41
|
-
return response;
|
|
42
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@selvajs/server` — transport-agnostic compute/solve server building blocks
|
|
3
|
-
* for apps built on the Selva engine.
|
|
4
|
-
*
|
|
5
|
-
* The compute stack (limits, per-key rate limiting, SSRF guard, input
|
|
6
|
-
* transform) that used to live inside the `@selvajs/selva` app, extracted so a
|
|
7
|
-
* consuming app can reuse it instead of copying it. Everything here is
|
|
8
|
-
* env-injected (no ambient `$env` / `process.env`) and framework-agnostic.
|
|
9
|
-
*
|
|
10
|
-
* The full compute subsurface is also available at `@selvajs/server/compute`,
|
|
11
|
-
* the definitions slice (DefinitionService, schema extraction/validation,
|
|
12
|
-
* render loader) at `@selvajs/server/definitions`, the provider wiring
|
|
13
|
-
* (registry-driven `createSelvaProviders`) at `@selvajs/server/providers`,
|
|
14
|
-
* the HMAC token codec at `@selvajs/server/tokens`, error reporting at
|
|
15
|
-
* `@selvajs/server/errors`, HTTP hardening helpers at `@selvajs/server/http`,
|
|
16
|
-
* access-rule input assembly at `@selvajs/server/access`, and deployment/ops
|
|
17
|
-
* helpers at `@selvajs/server/ops`.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export * from './compute/index.js';
|
|
21
|
-
export * from './definitions/index.js';
|
|
22
|
-
export * from './providers/index.js';
|
|
23
|
-
export * from './tokens/index.js';
|
|
24
|
-
export * from './errors/index.js';
|
|
25
|
-
export * from './http/index.js';
|
|
26
|
-
export * from './access/index.js';
|
|
27
|
-
export * from './ops/index.js';
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { isNewer } from '../semver.js';
|
|
3
|
-
|
|
4
|
-
describe('isNewer', () => {
|
|
5
|
-
it('detects a newer patch / minor / major', () => {
|
|
6
|
-
expect(isNewer('4.3.2', '4.3.1')).toBe(true);
|
|
7
|
-
expect(isNewer('4.4.0', '4.3.9')).toBe(true);
|
|
8
|
-
expect(isNewer('5.0.0', '4.9.9')).toBe(true);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('returns false for same or older versions', () => {
|
|
12
|
-
expect(isNewer('4.3.1', '4.3.1')).toBe(false);
|
|
13
|
-
expect(isNewer('4.3.0', '4.3.1')).toBe(false);
|
|
14
|
-
expect(isNewer('3.9.9', '4.0.0')).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('ignores pre-release suffixes on the stable channel (only stable bumps surface)', () => {
|
|
18
|
-
expect(isNewer('4.3.2-beta.1', '4.3.1')).toBe(true);
|
|
19
|
-
expect(isNewer('4.3.1-beta.1', '4.3.1')).toBe(false);
|
|
20
|
-
// Explicit stable channel matches the default.
|
|
21
|
-
expect(isNewer('4.3.1-beta.1', '4.3.1', 'stable')).toBe(false);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('orders pre-releases on the beta channel', () => {
|
|
25
|
-
// Successive betas of the same core.
|
|
26
|
-
expect(isNewer('4.6.0-beta.2', '4.6.0-beta.1', 'beta')).toBe(true);
|
|
27
|
-
expect(isNewer('4.6.0-beta.1', '4.6.0-beta.2', 'beta')).toBe(false);
|
|
28
|
-
// A newer core beta beats an older stable.
|
|
29
|
-
expect(isNewer('4.6.0-beta.1', '4.5.1', 'beta')).toBe(true);
|
|
30
|
-
// Stable of a core outranks any beta of the same core (promotion).
|
|
31
|
-
expect(isNewer('4.6.0', '4.6.0-beta.9', 'beta')).toBe(true);
|
|
32
|
-
expect(isNewer('4.6.0-beta.9', '4.6.0', 'beta')).toBe(false);
|
|
33
|
-
// Reverting to an OLDER stable is NOT "newer" — the channel switch, not
|
|
34
|
-
// isNewer, is what makes it actionable.
|
|
35
|
-
expect(isNewer('4.5.1', '4.6.0-beta.2', 'beta')).toBe(false);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('falls back to a string compare when unparseable', () => {
|
|
39
|
-
expect(isNewer('weird', 'weird')).toBe(false);
|
|
40
|
-
expect(isNewer('a', 'b')).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
});
|
package/src/ops/index.ts
DELETED
package/src/ops/semver.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Channel-aware semver comparison for update checks. Understands exactly the
|
|
3
|
-
* two-channel model Selva-engine deployments publish under: a stable line
|
|
4
|
-
* (`x.y.z`, npm `latest`) and a beta line (`x.y.z-beta.N`, npm `beta`).
|
|
5
|
-
* Deliberately not a general semver implementation — no build metadata, no
|
|
6
|
-
* arbitrary pre-release identifiers.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Which published line a deployment tracks. */
|
|
10
|
-
export type ReleaseChannel = 'stable' | 'beta';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Parse `x.y.z` plus an optional `-beta.N` pre-release counter. The
|
|
14
|
-
* pre-release number is returned separately so beta-channel comparisons can
|
|
15
|
-
* order successive betas of the same x.y.z core. Returns null for anything
|
|
16
|
-
* that doesn't lead with a numeric core.
|
|
17
|
-
*/
|
|
18
|
-
export function parseSemver(
|
|
19
|
-
v: string
|
|
20
|
-
): { core: [number, number, number]; beta: number | null } | null {
|
|
21
|
-
const m = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?/.exec(v.trim());
|
|
22
|
-
if (!m) return null;
|
|
23
|
-
return {
|
|
24
|
-
core: [Number(m[1]), Number(m[2]), Number(m[3])],
|
|
25
|
-
beta: m[4] != null ? Number(m[4]) : null
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* True when `latest` is strictly newer than `current` by semver.
|
|
31
|
-
*
|
|
32
|
-
* Stable channel (default): pre-release suffixes are ignored — only stable
|
|
33
|
-
* core bumps surface.
|
|
34
|
-
*
|
|
35
|
-
* Beta channel: pre-releases participate. A higher core wins; on an equal
|
|
36
|
-
* core, a higher `-beta.N` wins, and a stable (no suffix) outranks any -beta
|
|
37
|
-
* of the same core (4.6.0 > 4.6.0-beta.9). This lets an update UI show
|
|
38
|
-
* "beta.1 → beta.2" and "beta → stable promotion of the same line".
|
|
39
|
-
*
|
|
40
|
-
* Unparseable versions fall back to inequality: different strings count as
|
|
41
|
-
* "newer" so a weird published version still surfaces, identical ones don't.
|
|
42
|
-
*/
|
|
43
|
-
export function isNewer(
|
|
44
|
-
latest: string,
|
|
45
|
-
current: string,
|
|
46
|
-
channel: ReleaseChannel = 'stable'
|
|
47
|
-
): boolean {
|
|
48
|
-
const a = parseSemver(latest);
|
|
49
|
-
const b = parseSemver(current);
|
|
50
|
-
if (!a || !b) return latest !== current;
|
|
51
|
-
for (let i = 0; i < 3; i++) {
|
|
52
|
-
if (a.core[i] > b.core[i]) return true;
|
|
53
|
-
if (a.core[i] < b.core[i]) return false;
|
|
54
|
-
}
|
|
55
|
-
// Equal core. On the stable channel we don't compare pre-release tails.
|
|
56
|
-
if (channel !== 'beta') return false;
|
|
57
|
-
// Stable (beta == null) ranks above any pre-release of the same core.
|
|
58
|
-
const rank = (beta: number | null) => (beta == null ? Infinity : beta);
|
|
59
|
-
return rank(a.beta) > rank(b.beta);
|
|
60
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `createSelvaProviders` (K5) — registry-driven provider selection,
|
|
3
|
-
* lazy memoized instantiation, config parsing (flags/branding/tenancy), the
|
|
4
|
-
* solve-metrics duck-type pick, and the external config-path override.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import type { IAuthProvider, IDataProvider, IStorageProvider } from '@selvajs/platform';
|
|
10
|
-
import { createSelvaProviders, type ProviderRegistry } from '../create-selva-providers.js';
|
|
11
|
-
|
|
12
|
-
class FakeData {}
|
|
13
|
-
class FakeStorage {}
|
|
14
|
-
|
|
15
|
-
function makeRegistry() {
|
|
16
|
-
const authLocal = vi.fn(() => ({ name: 'local-auth' }) as unknown as IAuthProvider);
|
|
17
|
-
const authSupabase = vi.fn(() => ({ name: 'supabase-auth' }) as unknown as IAuthProvider);
|
|
18
|
-
const dataLocal = vi.fn(() => new FakeData() as unknown as IDataProvider);
|
|
19
|
-
const dataSupabase = vi.fn(() => new FakeData() as unknown as IDataProvider);
|
|
20
|
-
const storageLocal = vi.fn(() => new FakeStorage() as unknown as IStorageProvider);
|
|
21
|
-
const registry: ProviderRegistry = {
|
|
22
|
-
auth: { local: authLocal, supabase: authSupabase },
|
|
23
|
-
data: { local: dataLocal, supabase: dataSupabase },
|
|
24
|
-
storage: { local: storageLocal }
|
|
25
|
-
};
|
|
26
|
-
return { registry, authLocal, authSupabase, dataLocal, dataSupabase, storageLocal };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const noBoot = { onBoot: () => {} };
|
|
30
|
-
|
|
31
|
-
describe('registry selection', () => {
|
|
32
|
-
it('falls back to "local" for every slot when env is empty', async () => {
|
|
33
|
-
const { registry, authLocal, dataLocal, storageLocal } = makeRegistry();
|
|
34
|
-
const runtime = await createSelvaProviders({}, { registry, ...noBoot });
|
|
35
|
-
runtime.resolve();
|
|
36
|
-
expect(authLocal).toHaveBeenCalledTimes(1);
|
|
37
|
-
expect(dataLocal).toHaveBeenCalledTimes(1);
|
|
38
|
-
expect(storageLocal).toHaveBeenCalledTimes(1);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('honors SELVA_*_PROVIDER env choices, case-insensitively', async () => {
|
|
42
|
-
const { registry, authSupabase, dataSupabase } = makeRegistry();
|
|
43
|
-
const runtime = await createSelvaProviders(
|
|
44
|
-
{ SELVA_AUTH_PROVIDER: 'Supabase', SELVA_DATA_PROVIDER: 'SUPABASE' },
|
|
45
|
-
{ registry, ...noBoot }
|
|
46
|
-
);
|
|
47
|
-
runtime.resolve();
|
|
48
|
-
expect(authSupabase).toHaveBeenCalledTimes(1);
|
|
49
|
-
expect(dataSupabase).toHaveBeenCalledTimes(1);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('honors the defaults option over the built-in "local"', async () => {
|
|
53
|
-
const { registry, authSupabase } = makeRegistry();
|
|
54
|
-
const runtime = await createSelvaProviders(
|
|
55
|
-
{},
|
|
56
|
-
{ registry, defaults: { auth: 'supabase' }, ...noBoot }
|
|
57
|
-
);
|
|
58
|
-
runtime.resolve();
|
|
59
|
-
expect(authSupabase).toHaveBeenCalledTimes(1);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('rejects an unknown choice, listing the registered names', async () => {
|
|
63
|
-
const { registry } = makeRegistry();
|
|
64
|
-
const runtime = await createSelvaProviders(
|
|
65
|
-
{ SELVA_AUTH_PROVIDER: 'okta' },
|
|
66
|
-
{ registry, ...noBoot }
|
|
67
|
-
);
|
|
68
|
-
expect(() => runtime.resolve()).toThrow(/SELVA_AUTH_PROVIDER="okta".*local \| supabase/);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('laziness and memoization', () => {
|
|
73
|
-
it('does not instantiate providers until resolve() is called', async () => {
|
|
74
|
-
const { registry, authLocal } = makeRegistry();
|
|
75
|
-
await createSelvaProviders({}, { registry, ...noBoot });
|
|
76
|
-
expect(authLocal).not.toHaveBeenCalled();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('instantiates once and logs the boot summary once across repeated resolves', async () => {
|
|
80
|
-
const { registry, authLocal } = makeRegistry();
|
|
81
|
-
const onBoot = vi.fn();
|
|
82
|
-
const runtime = await createSelvaProviders({}, { registry, onBoot });
|
|
83
|
-
runtime.resolve();
|
|
84
|
-
runtime.resolve();
|
|
85
|
-
runtime.flag('ENABLE_SHARING');
|
|
86
|
-
expect(authLocal).toHaveBeenCalledTimes(1);
|
|
87
|
-
expect(onBoot).toHaveBeenCalledTimes(1);
|
|
88
|
-
expect(onBoot.mock.calls[0][0]).toContain('auth=local-auth');
|
|
89
|
-
expect(onBoot.mock.calls[0][0]).toContain('data=FakeData');
|
|
90
|
-
expect(onBoot.mock.calls[0][0]).toContain('tenancy=single');
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('config parsing', () => {
|
|
95
|
-
it('parses flags from SELVA_FLAG_* (omitted → false)', async () => {
|
|
96
|
-
const { registry } = makeRegistry();
|
|
97
|
-
const runtime = await createSelvaProviders(
|
|
98
|
-
{ SELVA_FLAG_ENABLE_SHARING: 'true', SELVA_FLAG_ALLOW_ORG_CREATION: 'no' },
|
|
99
|
-
{ registry, ...noBoot }
|
|
100
|
-
);
|
|
101
|
-
expect(runtime.flag('ENABLE_SHARING')).toBe(true);
|
|
102
|
-
expect(runtime.flag('ALLOW_ORG_CREATION')).toBe(false);
|
|
103
|
-
expect(runtime.flag('ALLOW_CROSS_ORG_PUBLIC')).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('parses tenancy and rejects unknown values', async () => {
|
|
107
|
-
const { registry } = makeRegistry();
|
|
108
|
-
const multi = await createSelvaProviders({ SELVA_TENANCY: 'multi' }, { registry, ...noBoot });
|
|
109
|
-
expect(multi.tenancy()).toBe('multi');
|
|
110
|
-
|
|
111
|
-
const bad = await createSelvaProviders({ SELVA_TENANCY: 'galactic' }, { registry, ...noBoot });
|
|
112
|
-
expect(() => bad.tenancy()).toThrow(/SELVA_TENANCY="galactic"/);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('brands with defaults; copyright falls back to the overridden name', async () => {
|
|
116
|
-
const { registry } = makeRegistry();
|
|
117
|
-
const plain = await createSelvaProviders({}, { registry, ...noBoot });
|
|
118
|
-
expect(plain.branding().name).toBe('Selva');
|
|
119
|
-
expect(plain.branding().copyrightName).toBe('Selva');
|
|
120
|
-
expect(plain.branding().tagline).toBeTruthy();
|
|
121
|
-
|
|
122
|
-
const branded = await createSelvaProviders(
|
|
123
|
-
{ SELVA_BRAND_NAME: ' Acme ' },
|
|
124
|
-
{ registry, ...noBoot }
|
|
125
|
-
);
|
|
126
|
-
expect(branded.branding().name).toBe('Acme');
|
|
127
|
-
expect(branded.branding().copyrightName).toBe('Acme');
|
|
128
|
-
expect(branded.branding().description).toContain('Acme');
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe('solve metric sink', () => {
|
|
133
|
-
it('falls back to a noop sink when the data provider has no solveMetrics', async () => {
|
|
134
|
-
const { registry } = makeRegistry();
|
|
135
|
-
const runtime = await createSelvaProviders({}, { registry, ...noBoot });
|
|
136
|
-
const sink = runtime.solveMetricSink();
|
|
137
|
-
expect(typeof sink.record).toBe('function');
|
|
138
|
-
expect(runtime.solveMetricSink()).toBe(sink); // memoized
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('reuses a duck-typed solveMetrics sink carried by the data provider', async () => {
|
|
142
|
-
const { registry } = makeRegistry();
|
|
143
|
-
const carried = { record: vi.fn() };
|
|
144
|
-
registry.data.local = vi.fn(() => ({ solveMetrics: carried }) as unknown as IDataProvider);
|
|
145
|
-
const runtime = await createSelvaProviders({}, { registry, ...noBoot });
|
|
146
|
-
expect(runtime.solveMetricSink()).toBe(carried);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe('config-path override', () => {
|
|
151
|
-
it('loads the module default export and hands it the env', async () => {
|
|
152
|
-
const { registry, authLocal } = makeRegistry();
|
|
153
|
-
const configPath = fileURLToPath(new URL('./fixtures/override-config.mjs', import.meta.url));
|
|
154
|
-
const runtime = await createSelvaProviders(
|
|
155
|
-
{ OVERRIDE_MARKER: 'hello' },
|
|
156
|
-
{ registry, configPath, ...noBoot }
|
|
157
|
-
);
|
|
158
|
-
const config = runtime.resolve();
|
|
159
|
-
expect(config.auth.name).toBe('override-auth');
|
|
160
|
-
expect((config.data as unknown as { marker: string }).marker).toBe('hello');
|
|
161
|
-
expect(runtime.tenancy()).toBe('multi');
|
|
162
|
-
// The registry is bypassed entirely.
|
|
163
|
-
expect(authLocal).not.toHaveBeenCalled();
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('rejects at load time when the path does not exist', async () => {
|
|
167
|
-
const { registry } = makeRegistry();
|
|
168
|
-
await expect(
|
|
169
|
-
createSelvaProviders({}, { registry, configPath: './does-not-exist.js', ...noBoot })
|
|
170
|
-
).rejects.toThrow(/does-not-exist\.js/);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Fixture for the SELVA_CONFIG_PATH override test — a minimal selva.config.js
|
|
2
|
-
// whose default export is a config factory, exactly as an operator would write.
|
|
3
|
-
export default (env) => ({
|
|
4
|
-
tenancy: 'multi',
|
|
5
|
-
auth: { name: 'override-auth' },
|
|
6
|
-
data: { marker: env.OVERRIDE_MARKER },
|
|
7
|
-
storage: {}
|
|
8
|
-
});
|