@selvajs/server 0.2.0-beta.1 → 0.2.0-beta.3

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 (192) hide show
  1. package/dist/access/__tests__/project-access-input.test.d.ts +2 -0
  2. package/dist/access/__tests__/project-access-input.test.d.ts.map +1 -0
  3. package/dist/access/__tests__/project-access-input.test.js +146 -0
  4. package/dist/access/__tests__/project-access-input.test.js.map +1 -0
  5. package/dist/access/index.d.ts +2 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/index.js +4 -0
  8. package/dist/access/index.js.map +1 -0
  9. package/dist/access/project-access-input.d.ts +52 -0
  10. package/dist/access/project-access-input.d.ts.map +1 -0
  11. package/dist/access/project-access-input.js +56 -0
  12. package/dist/access/project-access-input.js.map +1 -0
  13. package/dist/compute/__tests__/client-cache.test.js +70 -1
  14. package/dist/compute/__tests__/client-cache.test.js.map +1 -1
  15. package/dist/compute/__tests__/definition-byte-cache.test.d.ts +2 -0
  16. package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +1 -0
  17. package/dist/compute/__tests__/definition-byte-cache.test.js +107 -0
  18. package/dist/compute/__tests__/definition-byte-cache.test.js.map +1 -0
  19. package/dist/compute/__tests__/rate-limit.test.js +36 -0
  20. package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
  21. package/dist/compute/__tests__/solve-cache.test.d.ts +13 -0
  22. package/dist/compute/__tests__/solve-cache.test.d.ts.map +1 -0
  23. package/dist/compute/__tests__/solve-cache.test.js +234 -0
  24. package/dist/compute/__tests__/solve-cache.test.js.map +1 -0
  25. package/dist/compute/__tests__/solve-pipeline.test.js +327 -15
  26. package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
  27. package/dist/compute/__tests__/transform-input.test.js +7 -1
  28. package/dist/compute/__tests__/transform-input.test.js.map +1 -1
  29. package/dist/compute/client-cache.d.ts +32 -8
  30. package/dist/compute/client-cache.d.ts.map +1 -1
  31. package/dist/compute/client-cache.js +24 -6
  32. package/dist/compute/client-cache.js.map +1 -1
  33. package/dist/compute/definition-byte-cache.d.ts +85 -0
  34. package/dist/compute/definition-byte-cache.d.ts.map +1 -0
  35. package/dist/compute/definition-byte-cache.js +110 -0
  36. package/dist/compute/definition-byte-cache.js.map +1 -0
  37. package/dist/compute/index.d.ts +7 -2
  38. package/dist/compute/index.d.ts.map +1 -1
  39. package/dist/compute/index.js +6 -1
  40. package/dist/compute/index.js.map +1 -1
  41. package/dist/compute/limits.d.ts +62 -0
  42. package/dist/compute/limits.d.ts.map +1 -1
  43. package/dist/compute/limits.js +35 -1
  44. package/dist/compute/limits.js.map +1 -1
  45. package/dist/compute/memory-solve-cache.d.ts +58 -0
  46. package/dist/compute/memory-solve-cache.d.ts.map +1 -0
  47. package/dist/compute/memory-solve-cache.js +0 -0
  48. package/dist/compute/memory-solve-cache.js.map +1 -0
  49. package/dist/compute/rate-limit.d.ts +12 -0
  50. package/dist/compute/rate-limit.d.ts.map +1 -1
  51. package/dist/compute/rate-limit.js +18 -0
  52. package/dist/compute/rate-limit.js.map +1 -1
  53. package/dist/compute/solve-cache-envelope.d.ts +53 -0
  54. package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
  55. package/dist/compute/solve-cache-envelope.js +66 -0
  56. package/dist/compute/solve-cache-envelope.js.map +1 -0
  57. package/dist/compute/solve-cache-key.d.ts +65 -0
  58. package/dist/compute/solve-cache-key.d.ts.map +1 -0
  59. package/dist/compute/solve-cache-key.js +50 -0
  60. package/dist/compute/solve-cache-key.js.map +1 -0
  61. package/dist/compute/solve-cache-single-flight.d.ts +35 -0
  62. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
  63. package/dist/compute/solve-cache-single-flight.js +48 -0
  64. package/dist/compute/solve-cache-single-flight.js.map +1 -0
  65. package/dist/compute/solve-pipeline.d.ts +71 -8
  66. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  67. package/dist/compute/solve-pipeline.js +198 -15
  68. package/dist/compute/solve-pipeline.js.map +1 -1
  69. package/dist/compute/transform-input.d.ts.map +1 -1
  70. package/dist/compute/transform-input.js +21 -6
  71. package/dist/compute/transform-input.js.map +1 -1
  72. package/dist/definitions/__tests__/definition-service.test.d.ts +14 -0
  73. package/dist/definitions/__tests__/definition-service.test.d.ts.map +1 -0
  74. package/dist/definitions/__tests__/definition-service.test.js +95 -0
  75. package/dist/definitions/__tests__/definition-service.test.js.map +1 -0
  76. package/dist/definitions/definition-service.d.ts +1 -1
  77. package/dist/definitions/definition-service.d.ts.map +1 -1
  78. package/dist/definitions/definition-service.js +8 -4
  79. package/dist/definitions/definition-service.js.map +1 -1
  80. package/dist/errors/SentryErrorReporter.d.ts +17 -0
  81. package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
  82. package/dist/errors/SentryErrorReporter.js +60 -0
  83. package/dist/errors/SentryErrorReporter.js.map +1 -0
  84. package/dist/errors/index.d.ts +2 -0
  85. package/dist/errors/index.d.ts.map +1 -0
  86. package/dist/errors/index.js +3 -0
  87. package/dist/errors/index.js.map +1 -0
  88. package/dist/http/__tests__/body-size.test.d.ts +2 -0
  89. package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
  90. package/dist/http/__tests__/body-size.test.js +24 -0
  91. package/dist/http/__tests__/body-size.test.js.map +1 -0
  92. package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
  93. package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
  94. package/dist/http/__tests__/route-classifier.test.js +54 -0
  95. package/dist/http/__tests__/route-classifier.test.js.map +1 -0
  96. package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
  97. package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
  98. package/dist/http/__tests__/safe-redirect.test.js +44 -0
  99. package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
  100. package/dist/http/__tests__/security-headers.test.d.ts +2 -0
  101. package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
  102. package/dist/http/__tests__/security-headers.test.js +24 -0
  103. package/dist/http/__tests__/security-headers.test.js.map +1 -0
  104. package/dist/http/body-size.d.ts +21 -0
  105. package/dist/http/body-size.d.ts.map +1 -0
  106. package/dist/http/body-size.js +20 -0
  107. package/dist/http/body-size.js.map +1 -0
  108. package/dist/http/index.d.ts +5 -0
  109. package/dist/http/index.d.ts.map +1 -0
  110. package/dist/http/index.js +6 -0
  111. package/dist/http/index.js.map +1 -0
  112. package/dist/http/redirect.d.ts +11 -0
  113. package/dist/http/redirect.d.ts.map +1 -0
  114. package/dist/http/redirect.js +20 -0
  115. package/dist/http/redirect.js.map +1 -0
  116. package/dist/http/route-classifier.d.ts +47 -0
  117. package/dist/http/route-classifier.d.ts.map +1 -0
  118. package/dist/http/route-classifier.js +37 -0
  119. package/dist/http/route-classifier.js.map +1 -0
  120. package/dist/http/security-headers.d.ts +31 -0
  121. package/dist/http/security-headers.d.ts.map +1 -0
  122. package/dist/http/security-headers.js +31 -0
  123. package/dist/http/security-headers.js.map +1 -0
  124. package/dist/index.d.ts +11 -2
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +11 -2
  127. package/dist/index.js.map +1 -1
  128. package/dist/ops/__tests__/semver.test.d.ts +2 -0
  129. package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
  130. package/dist/ops/__tests__/semver.test.js +38 -0
  131. package/dist/ops/__tests__/semver.test.js.map +1 -0
  132. package/dist/ops/index.d.ts +2 -0
  133. package/dist/ops/index.d.ts.map +1 -0
  134. package/dist/ops/index.js +3 -0
  135. package/dist/ops/index.js.map +1 -0
  136. package/dist/ops/semver.d.ts +35 -0
  137. package/dist/ops/semver.d.ts.map +1 -0
  138. package/dist/ops/semver.js +55 -0
  139. package/dist/ops/semver.js.map +1 -0
  140. package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
  141. package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
  142. package/dist/tokens/__tests__/token-codec.test.js +50 -0
  143. package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
  144. package/dist/tokens/index.d.ts +2 -0
  145. package/dist/tokens/index.d.ts.map +1 -0
  146. package/dist/tokens/index.js +3 -0
  147. package/dist/tokens/index.js.map +1 -0
  148. package/dist/tokens/token-codec.d.ts +54 -0
  149. package/dist/tokens/token-codec.d.ts.map +1 -0
  150. package/dist/tokens/token-codec.js +62 -0
  151. package/dist/tokens/token-codec.js.map +1 -0
  152. package/package.json +35 -2
  153. package/src/access/__tests__/project-access-input.test.ts +175 -0
  154. package/src/access/index.ts +9 -0
  155. package/src/access/project-access-input.ts +145 -0
  156. package/src/compute/__tests__/client-cache.test.ts +81 -1
  157. package/src/compute/__tests__/definition-byte-cache.test.ts +129 -0
  158. package/src/compute/__tests__/rate-limit.test.ts +37 -0
  159. package/src/compute/__tests__/solve-cache.test.ts +268 -0
  160. package/src/compute/__tests__/solve-pipeline.test.ts +380 -14
  161. package/src/compute/__tests__/transform-input.test.ts +8 -1
  162. package/src/compute/client-cache.ts +59 -16
  163. package/src/compute/definition-byte-cache.ts +168 -0
  164. package/src/compute/index.ts +34 -0
  165. package/src/compute/limits.ts +91 -1
  166. package/src/compute/memory-solve-cache.ts +0 -0
  167. package/src/compute/rate-limit.ts +27 -0
  168. package/src/compute/solve-cache-envelope.ts +98 -0
  169. package/src/compute/solve-cache-key.ts +85 -0
  170. package/src/compute/solve-cache-single-flight.ts +59 -0
  171. package/src/compute/solve-pipeline.ts +301 -22
  172. package/src/compute/transform-input.ts +20 -6
  173. package/src/definitions/__tests__/definition-service.test.ts +131 -0
  174. package/src/definitions/definition-service.ts +8 -4
  175. package/src/errors/SentryErrorReporter.ts +88 -0
  176. package/src/errors/index.ts +3 -0
  177. package/src/http/__tests__/body-size.test.ts +27 -0
  178. package/src/http/__tests__/route-classifier.test.ts +60 -0
  179. package/src/http/__tests__/safe-redirect.test.ts +47 -0
  180. package/src/http/__tests__/security-headers.test.ts +29 -0
  181. package/src/http/body-size.ts +24 -0
  182. package/src/http/index.ts +10 -0
  183. package/src/http/redirect.ts +16 -0
  184. package/src/http/route-classifier.ts +74 -0
  185. package/src/http/security-headers.ts +42 -0
  186. package/src/index.ts +11 -2
  187. package/src/ops/__tests__/semver.test.ts +42 -0
  188. package/src/ops/index.ts +3 -0
  189. package/src/ops/semver.ts +60 -0
  190. package/src/tokens/__tests__/token-codec.test.ts +63 -0
  191. package/src/tokens/index.ts +8 -0
  192. package/src/tokens/token-codec.ts +86 -0
@@ -0,0 +1,88 @@
1
+ import type { ErrorContext, IErrorReporter } from '@selvajs/platform';
2
+
3
+ /**
4
+ * `IErrorReporter` backed by Sentry (`@sentry/node`). Constructed only when
5
+ * the consuming app has a DSN configured. Deployments that don't configure a
6
+ * DSN never load `@sentry/node`, so it stays an OPTIONAL peer dependency: the
7
+ * base install ships without it and the no-op path needs no package at all.
8
+ *
9
+ * `@sentry/node` is imported dynamically and its surface is narrowed to the
10
+ * two calls we use — a hard static import would make the package mandatory.
11
+ */
12
+ interface SentryLike {
13
+ init(options: {
14
+ dsn: string;
15
+ environment?: string;
16
+ release?: string;
17
+ tracesSampleRate: number;
18
+ }): void;
19
+ captureException(
20
+ error: unknown,
21
+ context?: {
22
+ tags?: Record<string, string>;
23
+ user?: { id?: string };
24
+ extra?: Record<string, unknown>;
25
+ }
26
+ ): void;
27
+ }
28
+
29
+ export class SentryErrorReporter implements IErrorReporter {
30
+ private constructor(private readonly sentry: SentryLike) {}
31
+
32
+ /**
33
+ * Initialize Sentry and return a reporter, or `null` if `@sentry/node` is
34
+ * not installed (optional dependency absent). Never throws — a broken
35
+ * error-tracker must not take down boot.
36
+ */
37
+ static async create(opts: {
38
+ dsn: string;
39
+ environment?: string;
40
+ release?: string;
41
+ }): Promise<SentryErrorReporter | null> {
42
+ try {
43
+ // Dynamic so the package stays optional. `@vite-ignore` keeps the
44
+ // bundler from trying to resolve it at build time when it's absent.
45
+ // `@ts-ignore` (not `@ts-expect-error`) because whether the module
46
+ // resolves at type-check time depends on the environment — it's a real
47
+ // dep in some installs, absent in others — so we can't assert the error
48
+ // is always present. The `as unknown as` cast narrows whatever loads to
49
+ // the two calls we use.
50
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
51
+ // @ts-ignore -- optional dependency, may be absent at type-check time
52
+ const sentry = (await import(/* @vite-ignore */ '@sentry/node')) as unknown as SentryLike;
53
+ sentry.init({
54
+ dsn: opts.dsn,
55
+ environment: opts.environment,
56
+ release: opts.release,
57
+ // Error tracking only — no performance tracing by default. Flip
58
+ // this up later if you want transactions/spans.
59
+ tracesSampleRate: 0
60
+ });
61
+ return new SentryErrorReporter(sentry);
62
+ } catch (err) {
63
+ console.error(
64
+ '[ErrorReporter] A Sentry DSN is set but @sentry/node could not be loaded. ' +
65
+ 'Install it (`pnpm add @sentry/node`) or unset the DSN. Falling back to no-op.',
66
+ err
67
+ );
68
+ return null;
69
+ }
70
+ }
71
+
72
+ capture(error: unknown, context?: ErrorContext): void {
73
+ try {
74
+ const tags: Record<string, string> = { ...context?.tags };
75
+ if (context?.method) tags.method = context.method;
76
+ if (context?.route) tags.route = context.route;
77
+ if (context?.orgId) tags.orgId = context.orgId;
78
+ this.sentry.captureException(error, {
79
+ tags,
80
+ user: context?.userId ? { id: context.userId } : undefined
81
+ });
82
+ } catch (err) {
83
+ // Reporting is best-effort and sits on the error path — a second
84
+ // failure here would mask the first. Swallow after logging.
85
+ console.error('[ErrorReporter] Failed to capture error:', err);
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,3 @@
1
+ // Error reporting — Sentry-backed IErrorReporter (optional @sentry/node peer).
2
+
3
+ export { SentryErrorReporter } from './SentryErrorReporter.js';
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { declaredBodySizeExceeds } from '../body-size.js';
3
+
4
+ function headersWith(contentLength?: string): Headers {
5
+ const h = new Headers();
6
+ if (contentLength !== undefined) h.set('content-length', contentLength);
7
+ return h;
8
+ }
9
+
10
+ describe('declaredBodySizeExceeds', () => {
11
+ it('flags a declared size over the limit', () => {
12
+ expect(declaredBodySizeExceeds(headersWith('1001'), 1000)).toBe(true);
13
+ });
14
+
15
+ it('passes a declared size at or under the limit', () => {
16
+ expect(declaredBodySizeExceeds(headersWith('1000'), 1000)).toBe(false);
17
+ expect(declaredBodySizeExceeds(headersWith('0'), 1000)).toBe(false);
18
+ });
19
+
20
+ it('passes when Content-Length is absent (chunked transfer — global limit is the backstop)', () => {
21
+ expect(declaredBodySizeExceeds(headersWith(), 1000)).toBe(false);
22
+ });
23
+
24
+ it('passes a non-numeric Content-Length rather than throwing', () => {
25
+ expect(declaredBodySizeExceeds(headersWith('not-a-number'), 1000)).toBe(false);
26
+ });
27
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createRouteClassifier } from '../route-classifier.js';
3
+
4
+ // Generic values — the consuming app's actual route policy is pinned by its
5
+ // own test suite; these cover the classifier mechanics.
6
+ const classifier = createRouteClassifier({
7
+ publicPages: ['/', '/login'],
8
+ publicPrefixes: ['/auth/', '/logout'],
9
+ publicApis: ['/api/health'],
10
+ selfGatingPrefix: '/api/files/',
11
+ staticPrefixes: ['/_app/', '/favicon/'],
12
+ staticPaths: ['/favicon.svg', '/robots.txt']
13
+ });
14
+
15
+ describe('createRouteClassifier', () => {
16
+ it('classifies exact pages, API allowlist, prefixes, and self-gating as public', () => {
17
+ for (const path of [
18
+ '/',
19
+ '/login',
20
+ '/api/health',
21
+ '/auth/oauth/callback',
22
+ '/logout',
23
+ '/api/files/orgs/abc/logo.webp'
24
+ ]) {
25
+ expect(classifier.isPublicRoute(path)).toBe(true);
26
+ }
27
+ });
28
+
29
+ it('denies by default — unknown routes are gated', () => {
30
+ for (const path of ['/library', '/api/projects', '/billing', '/whatever']) {
31
+ expect(classifier.isPublicRoute(path)).toBe(false);
32
+ }
33
+ });
34
+
35
+ it('exact-match pages do not admit siblings via prefix', () => {
36
+ expect(classifier.isPublicRoute('/login-other')).toBe(false);
37
+ expect(classifier.isPublicRoute('/api/healthz')).toBe(false);
38
+ });
39
+
40
+ it('self-gating prefix is not a loose match', () => {
41
+ expect(classifier.isSelfGatingApiRoute('/api/files/x')).toBe(true);
42
+ expect(classifier.isSelfGatingApiRoute('/api/filesX/y')).toBe(false);
43
+ expect(classifier.isSelfGatingApiRoute('/api/projects')).toBe(false);
44
+ });
45
+
46
+ it('recognizes static assets by prefix and exact path', () => {
47
+ expect(classifier.isStaticAsset('/_app/immutable/x.js')).toBe(true);
48
+ expect(classifier.isStaticAsset('/favicon/16.png')).toBe(true);
49
+ expect(classifier.isStaticAsset('/robots.txt')).toBe(true);
50
+ expect(classifier.isStaticAsset('/favicon-other.png')).toBe(false);
51
+ expect(classifier.isStaticAsset('/login')).toBe(false);
52
+ });
53
+
54
+ it('every allowlist is optional — an empty config gates everything', () => {
55
+ const closed = createRouteClassifier({});
56
+ expect(closed.isPublicRoute('/')).toBe(false);
57
+ expect(closed.isStaticAsset('/robots.txt')).toBe(false);
58
+ expect(closed.isSelfGatingApiRoute('/api/files/x')).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { safeRedirectTarget } from '../redirect.js';
3
+
4
+ /**
5
+ * `safeRedirectTarget` is the post-login redirect validator. The point of
6
+ * the function is to reject *exactly* the values that would otherwise
7
+ * smuggle an external host into a `redirect(303, …)` call:
8
+ * protocol-relative URLs (`//evil.com`) and back-slash variants some
9
+ * browsers normalize as a path separator. Each row below is one such case.
10
+ */
11
+ describe('safeRedirectTarget', () => {
12
+ it.each([
13
+ ['/foo', '/foo'],
14
+ ['/foo/bar', '/foo/bar'],
15
+ ['/foo?x=1', '/foo?x=1'],
16
+ ['/foo#frag', '/foo#frag'],
17
+ ['/a', '/a'] // shortest valid path: `/` + non-`/` char
18
+ ])('accepts same-origin path %j → %j', (input, expected) => {
19
+ expect(safeRedirectTarget(input, '/fallback')).toBe(expected);
20
+ });
21
+
22
+ it.each([
23
+ ['//evil.com', 'protocol-relative URL'],
24
+ ['//evil.com/path', 'protocol-relative URL with path'],
25
+ ['/\\evil.com', 'back-slash bypass'],
26
+ ['https://evil.com', 'absolute URL'],
27
+ ['http://evil.com', 'absolute URL'],
28
+ ['javascript:alert(1)', 'javascript: scheme'],
29
+ ['', 'empty string'],
30
+ ['/', 'lone slash (length<2)'],
31
+ ['evil.com', 'no leading slash'],
32
+ ['./relative', 'relative path']
33
+ ])('rejects %j (%s)', (input) => {
34
+ expect(safeRedirectTarget(input, '/fallback')).toBe('/fallback');
35
+ });
36
+
37
+ it.each([null, undefined])('rejects %j', (input) => {
38
+ expect(safeRedirectTarget(input, '/fallback')).toBe('/fallback');
39
+ });
40
+
41
+ it('passes through arbitrary non-string types as fallback', () => {
42
+ // safeRedirectTarget's signature is `string | null | undefined`, but
43
+ // some call sites get `unknown` from FormData — make sure we don't
44
+ // throw on a stray non-string.
45
+ expect(safeRedirectTarget(42 as unknown as string, '/fallback')).toBe('/fallback');
46
+ });
47
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { applySecurityHeaders } from '../security-headers.js';
3
+
4
+ describe('applySecurityHeaders', () => {
5
+ it('sets the baseline hardening headers and returns the same response', () => {
6
+ const response = new Response('ok');
7
+ const out = applySecurityHeaders(response, { hsts: false });
8
+ expect(out).toBe(response);
9
+ expect(out.headers.get('X-Content-Type-Options')).toBe('nosniff');
10
+ expect(out.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
11
+ expect(out.headers.get('Permissions-Policy')).toContain('camera=()');
12
+ });
13
+
14
+ it('sets HSTS only when asked (production)', () => {
15
+ const dev = applySecurityHeaders(new Response('ok'), { hsts: false });
16
+ expect(dev.headers.get('Strict-Transport-Security')).toBeNull();
17
+
18
+ const prod = applySecurityHeaders(new Response('ok'), { hsts: true });
19
+ expect(prod.headers.get('Strict-Transport-Security')).toBe(
20
+ 'max-age=31536000; includeSubDomains'
21
+ );
22
+ });
23
+
24
+ it('never sets CSP or frame headers (iframe embedding is a product requirement)', () => {
25
+ const out = applySecurityHeaders(new Response('ok'), { hsts: true });
26
+ expect(out.headers.get('Content-Security-Policy')).toBeNull();
27
+ expect(out.headers.get('X-Frame-Options')).toBeNull();
28
+ });
29
+ });
@@ -0,0 +1,24 @@
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
+ }
@@ -0,0 +1,10 @@
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';
@@ -0,0 +1,16 @@
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
+ }
@@ -0,0 +1,74 @@
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
+ }
@@ -0,0 +1,42 @@
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 CHANGED
@@ -9,10 +9,19 @@
9
9
  *
10
10
  * The full compute subsurface is also available at `@selvajs/server/compute`,
11
11
  * the definitions slice (DefinitionService, schema extraction/validation,
12
- * render loader) at `@selvajs/server/definitions`, and the provider wiring
13
- * (registry-driven `createSelvaProviders`) at `@selvajs/server/providers`.
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`.
14
18
  */
15
19
 
16
20
  export * from './compute/index.js';
17
21
  export * from './definitions/index.js';
18
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';
@@ -0,0 +1,42 @@
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
+ });
@@ -0,0 +1,3 @@
1
+ // Deployment/ops helpers — channel-aware semver comparison.
2
+
3
+ export { parseSemver, isNewer, type ReleaseChannel } from './semver.js';
@@ -0,0 +1,60 @@
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
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createTokenCodec, MIN_TOKEN_SECRET_LENGTH } from '../token-codec.js';
3
+
4
+ const SECRET = 'unit-test-secret-that-is-long-enough-to-pass';
5
+
6
+ describe('createTokenCodec', () => {
7
+ it('rejects a secret shorter than the minimum', () => {
8
+ expect(() => createTokenCodec({ prefix: 'share_', secret: 'short' })).toThrow(
9
+ /at least 32 characters/
10
+ );
11
+ expect(() =>
12
+ createTokenCodec({ prefix: 'share_', secret: 'x'.repeat(MIN_TOKEN_SECRET_LENGTH - 1) })
13
+ ).toThrow(/got 31/);
14
+ });
15
+
16
+ it('accepts a secret at exactly the minimum length', () => {
17
+ expect(() =>
18
+ createTokenCodec({ prefix: 'share_', secret: 'x'.repeat(MIN_TOKEN_SECRET_LENGTH) })
19
+ ).not.toThrow();
20
+ });
21
+
22
+ it('mints prefixed, unique, URL-safe tokens', () => {
23
+ const codec = createTokenCodec({ prefix: 'share_', secret: SECRET });
24
+ const a = codec.mintRawToken();
25
+ const b = codec.mintRawToken();
26
+ expect(a).toMatch(/^share_[A-Za-z0-9_-]{43}$/); // 32 bytes → 43 base64url chars
27
+ expect(a).not.toBe(b);
28
+ });
29
+
30
+ it('hashes deterministically, never echoing the raw token', () => {
31
+ const codec = createTokenCodec({ prefix: 'share_', secret: SECRET });
32
+ const raw = codec.mintRawToken();
33
+ const hash = codec.hashToken(raw);
34
+ expect(codec.hashToken(raw)).toBe(hash);
35
+ expect(hash).not.toContain(raw);
36
+ expect(hash).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
37
+ });
38
+
39
+ it('produces different hashes under different secrets (rotation invalidates)', () => {
40
+ const a = createTokenCodec({ prefix: 'share_', secret: SECRET });
41
+ const b = createTokenCodec({ prefix: 'share_', secret: SECRET + '-rotated' });
42
+ const raw = a.mintRawToken();
43
+ expect(a.hashToken(raw)).not.toBe(b.hashToken(raw));
44
+ });
45
+
46
+ it('hashesEqual: equal, unequal, and length-mismatch inputs', () => {
47
+ const codec = createTokenCodec({ prefix: 'invite_', secret: SECRET });
48
+ const hash = codec.hashToken(codec.mintRawToken());
49
+ const other = codec.hashToken(codec.mintRawToken());
50
+ expect(codec.hashesEqual(hash, hash)).toBe(true);
51
+ expect(codec.hashesEqual(hash, other)).toBe(false);
52
+ expect(codec.hashesEqual(hash, hash.slice(0, -1))).toBe(false);
53
+ });
54
+
55
+ it('looksLikeToken recognizes only its own prefix', () => {
56
+ const share = createTokenCodec({ prefix: 'share_', secret: SECRET });
57
+ const invite = createTokenCodec({ prefix: 'invite_', secret: SECRET });
58
+ const raw = share.mintRawToken();
59
+ expect(share.looksLikeToken(raw)).toBe(true);
60
+ expect(invite.looksLikeToken(raw)).toBe(false);
61
+ expect(share.looksLikeToken('random-string')).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,8 @@
1
+ // HMAC token codec for capability-URL tokens (share links, invites, …).
2
+
3
+ export {
4
+ createTokenCodec,
5
+ MIN_TOKEN_SECRET_LENGTH,
6
+ type TokenCodec,
7
+ type TokenCodecConfig
8
+ } from './token-codec.js';