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

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 (141) 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 +52 -1
  14. package/dist/compute/__tests__/client-cache.test.js.map +1 -1
  15. package/dist/compute/__tests__/rate-limit.test.js +36 -0
  16. package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
  17. package/dist/compute/__tests__/solve-pipeline.test.js +60 -13
  18. package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
  19. package/dist/compute/__tests__/transform-input.test.js +7 -1
  20. package/dist/compute/__tests__/transform-input.test.js.map +1 -1
  21. package/dist/compute/client-cache.d.ts +20 -8
  22. package/dist/compute/client-cache.d.ts.map +1 -1
  23. package/dist/compute/client-cache.js +12 -3
  24. package/dist/compute/client-cache.js.map +1 -1
  25. package/dist/compute/limits.d.ts +10 -0
  26. package/dist/compute/limits.d.ts.map +1 -1
  27. package/dist/compute/limits.js +3 -1
  28. package/dist/compute/limits.js.map +1 -1
  29. package/dist/compute/rate-limit.d.ts +12 -0
  30. package/dist/compute/rate-limit.d.ts.map +1 -1
  31. package/dist/compute/rate-limit.js +18 -0
  32. package/dist/compute/rate-limit.js.map +1 -1
  33. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  34. package/dist/compute/solve-pipeline.js +20 -7
  35. package/dist/compute/solve-pipeline.js.map +1 -1
  36. package/dist/compute/transform-input.d.ts.map +1 -1
  37. package/dist/compute/transform-input.js +21 -6
  38. package/dist/compute/transform-input.js.map +1 -1
  39. package/dist/errors/SentryErrorReporter.d.ts +17 -0
  40. package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
  41. package/dist/errors/SentryErrorReporter.js +60 -0
  42. package/dist/errors/SentryErrorReporter.js.map +1 -0
  43. package/dist/errors/index.d.ts +2 -0
  44. package/dist/errors/index.d.ts.map +1 -0
  45. package/dist/errors/index.js +3 -0
  46. package/dist/errors/index.js.map +1 -0
  47. package/dist/http/__tests__/body-size.test.d.ts +2 -0
  48. package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
  49. package/dist/http/__tests__/body-size.test.js +24 -0
  50. package/dist/http/__tests__/body-size.test.js.map +1 -0
  51. package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
  52. package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
  53. package/dist/http/__tests__/route-classifier.test.js +54 -0
  54. package/dist/http/__tests__/route-classifier.test.js.map +1 -0
  55. package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
  56. package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
  57. package/dist/http/__tests__/safe-redirect.test.js +44 -0
  58. package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
  59. package/dist/http/__tests__/security-headers.test.d.ts +2 -0
  60. package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
  61. package/dist/http/__tests__/security-headers.test.js +24 -0
  62. package/dist/http/__tests__/security-headers.test.js.map +1 -0
  63. package/dist/http/body-size.d.ts +21 -0
  64. package/dist/http/body-size.d.ts.map +1 -0
  65. package/dist/http/body-size.js +20 -0
  66. package/dist/http/body-size.js.map +1 -0
  67. package/dist/http/index.d.ts +5 -0
  68. package/dist/http/index.d.ts.map +1 -0
  69. package/dist/http/index.js +6 -0
  70. package/dist/http/index.js.map +1 -0
  71. package/dist/http/redirect.d.ts +11 -0
  72. package/dist/http/redirect.d.ts.map +1 -0
  73. package/dist/http/redirect.js +20 -0
  74. package/dist/http/redirect.js.map +1 -0
  75. package/dist/http/route-classifier.d.ts +47 -0
  76. package/dist/http/route-classifier.d.ts.map +1 -0
  77. package/dist/http/route-classifier.js +37 -0
  78. package/dist/http/route-classifier.js.map +1 -0
  79. package/dist/http/security-headers.d.ts +31 -0
  80. package/dist/http/security-headers.d.ts.map +1 -0
  81. package/dist/http/security-headers.js +31 -0
  82. package/dist/http/security-headers.js.map +1 -0
  83. package/dist/index.d.ts +11 -2
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +11 -2
  86. package/dist/index.js.map +1 -1
  87. package/dist/ops/__tests__/semver.test.d.ts +2 -0
  88. package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
  89. package/dist/ops/__tests__/semver.test.js +38 -0
  90. package/dist/ops/__tests__/semver.test.js.map +1 -0
  91. package/dist/ops/index.d.ts +2 -0
  92. package/dist/ops/index.d.ts.map +1 -0
  93. package/dist/ops/index.js +3 -0
  94. package/dist/ops/index.js.map +1 -0
  95. package/dist/ops/semver.d.ts +35 -0
  96. package/dist/ops/semver.d.ts.map +1 -0
  97. package/dist/ops/semver.js +55 -0
  98. package/dist/ops/semver.js.map +1 -0
  99. package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
  100. package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
  101. package/dist/tokens/__tests__/token-codec.test.js +50 -0
  102. package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
  103. package/dist/tokens/index.d.ts +2 -0
  104. package/dist/tokens/index.d.ts.map +1 -0
  105. package/dist/tokens/index.js +3 -0
  106. package/dist/tokens/index.js.map +1 -0
  107. package/dist/tokens/token-codec.d.ts +54 -0
  108. package/dist/tokens/token-codec.d.ts.map +1 -0
  109. package/dist/tokens/token-codec.js +62 -0
  110. package/dist/tokens/token-codec.js.map +1 -0
  111. package/package.json +35 -2
  112. package/src/access/__tests__/project-access-input.test.ts +175 -0
  113. package/src/access/index.ts +9 -0
  114. package/src/access/project-access-input.ts +145 -0
  115. package/src/compute/__tests__/client-cache.test.ts +61 -1
  116. package/src/compute/__tests__/rate-limit.test.ts +37 -0
  117. package/src/compute/__tests__/solve-pipeline.test.ts +60 -13
  118. package/src/compute/__tests__/transform-input.test.ts +8 -1
  119. package/src/compute/client-cache.ts +35 -13
  120. package/src/compute/limits.ts +13 -1
  121. package/src/compute/rate-limit.ts +27 -0
  122. package/src/compute/solve-pipeline.ts +22 -7
  123. package/src/compute/transform-input.ts +20 -6
  124. package/src/errors/SentryErrorReporter.ts +88 -0
  125. package/src/errors/index.ts +3 -0
  126. package/src/http/__tests__/body-size.test.ts +27 -0
  127. package/src/http/__tests__/route-classifier.test.ts +60 -0
  128. package/src/http/__tests__/safe-redirect.test.ts +47 -0
  129. package/src/http/__tests__/security-headers.test.ts +29 -0
  130. package/src/http/body-size.ts +24 -0
  131. package/src/http/index.ts +10 -0
  132. package/src/http/redirect.ts +16 -0
  133. package/src/http/route-classifier.ts +74 -0
  134. package/src/http/security-headers.ts +42 -0
  135. package/src/index.ts +11 -2
  136. package/src/ops/__tests__/semver.test.ts +42 -0
  137. package/src/ops/index.ts +3 -0
  138. package/src/ops/semver.ts +60 -0
  139. package/src/tokens/__tests__/token-codec.test.ts +63 -0
  140. package/src/tokens/index.ts +8 -0
  141. package/src/tokens/token-codec.ts +86 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semver.test.js","sourceRoot":"","sources":["../../../src/ops/__tests__/semver.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,QAAQ,CAAC,SAAS,EAAE,GAAG,EAAE;IACxB,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAChD,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QACnD,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9C,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9C,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gFAAgF,EAAE,GAAG,EAAE;QACzF,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACpD,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACrD,+CAA+C;QAC/C,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QAClD,qCAAqC;QACrC,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnE,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACpE,2CAA2C;QAC3C,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,mEAAmE;QACnE,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7D,wEAAwE;QACxE,wCAAwC;QACxC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QAC1D,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9C,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { parseSemver, isNewer, type ReleaseChannel } from './semver.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,3 @@
1
+ // Deployment/ops helpers — channel-aware semver comparison.
2
+ export { parseSemver, isNewer } from './semver.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAE5D,OAAO,EAAE,WAAW,EAAE,OAAO,EAAuB,MAAM,aAAa,CAAC"}
@@ -0,0 +1,35 @@
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
+ /** Which published line a deployment tracks. */
9
+ export type ReleaseChannel = 'stable' | 'beta';
10
+ /**
11
+ * Parse `x.y.z` plus an optional `-beta.N` pre-release counter. The
12
+ * pre-release number is returned separately so beta-channel comparisons can
13
+ * order successive betas of the same x.y.z core. Returns null for anything
14
+ * that doesn't lead with a numeric core.
15
+ */
16
+ export declare function parseSemver(v: string): {
17
+ core: [number, number, number];
18
+ beta: number | null;
19
+ } | null;
20
+ /**
21
+ * True when `latest` is strictly newer than `current` by semver.
22
+ *
23
+ * Stable channel (default): pre-release suffixes are ignored — only stable
24
+ * core bumps surface.
25
+ *
26
+ * Beta channel: pre-releases participate. A higher core wins; on an equal
27
+ * core, a higher `-beta.N` wins, and a stable (no suffix) outranks any -beta
28
+ * of the same core (4.6.0 > 4.6.0-beta.9). This lets an update UI show
29
+ * "beta.1 → beta.2" and "beta → stable promotion of the same line".
30
+ *
31
+ * Unparseable versions fall back to inequality: different strings count as
32
+ * "newer" so a weird published version still surfaces, identical ones don't.
33
+ */
34
+ export declare function isNewer(latest: string, current: string, channel?: ReleaseChannel): boolean;
35
+ //# sourceMappingURL=semver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semver.d.ts","sourceRoot":"","sources":["../../src/ops/semver.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,gDAAgD;AAChD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE/C;;;;;GAKG;AACH,wBAAgB,WAAW,CAC1B,CAAC,EAAE,MAAM,GACP;IAAE,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAOhE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CACtB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,cAAyB,GAChC,OAAO,CAaT"}
@@ -0,0 +1,55 @@
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
+ * Parse `x.y.z` plus an optional `-beta.N` pre-release counter. The
10
+ * pre-release number is returned separately so beta-channel comparisons can
11
+ * order successive betas of the same x.y.z core. Returns null for anything
12
+ * that doesn't lead with a numeric core.
13
+ */
14
+ export function parseSemver(v) {
15
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?/.exec(v.trim());
16
+ if (!m)
17
+ return null;
18
+ return {
19
+ core: [Number(m[1]), Number(m[2]), Number(m[3])],
20
+ beta: m[4] != null ? Number(m[4]) : null
21
+ };
22
+ }
23
+ /**
24
+ * True when `latest` is strictly newer than `current` by semver.
25
+ *
26
+ * Stable channel (default): pre-release suffixes are ignored — only stable
27
+ * core bumps surface.
28
+ *
29
+ * Beta channel: pre-releases participate. A higher core wins; on an equal
30
+ * core, a higher `-beta.N` wins, and a stable (no suffix) outranks any -beta
31
+ * of the same core (4.6.0 > 4.6.0-beta.9). This lets an update UI show
32
+ * "beta.1 → beta.2" and "beta → stable promotion of the same line".
33
+ *
34
+ * Unparseable versions fall back to inequality: different strings count as
35
+ * "newer" so a weird published version still surfaces, identical ones don't.
36
+ */
37
+ export function isNewer(latest, current, channel = 'stable') {
38
+ const a = parseSemver(latest);
39
+ const b = parseSemver(current);
40
+ if (!a || !b)
41
+ return latest !== current;
42
+ for (let i = 0; i < 3; i++) {
43
+ if (a.core[i] > b.core[i])
44
+ return true;
45
+ if (a.core[i] < b.core[i])
46
+ return false;
47
+ }
48
+ // Equal core. On the stable channel we don't compare pre-release tails.
49
+ if (channel !== 'beta')
50
+ return false;
51
+ // Stable (beta == null) ranks above any pre-release of the same core.
52
+ const rank = (beta) => (beta == null ? Infinity : beta);
53
+ return rank(a.beta) > rank(b.beta);
54
+ }
55
+ //# sourceMappingURL=semver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semver.js","sourceRoot":"","sources":["../../src/ops/semver.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAC1B,CAAS;IAET,MAAM,CAAC,GAAG,uCAAuC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACjE,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,OAAO;QACN,IAAI,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAChD,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI;KACxC,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,OAAO,CACtB,MAAc,EACd,OAAe,EACf,UAA0B,QAAQ;IAElC,MAAM,CAAC,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;IAC9B,MAAM,CAAC,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;IAC/B,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;QAAE,OAAO,MAAM,KAAK,OAAO,CAAC;IACxC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QACvC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;IACzC,CAAC;IACD,wEAAwE;IACxE,IAAI,OAAO,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACrC,sEAAsE;IACtE,MAAM,IAAI,GAAG,CAAC,IAAmB,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IACvE,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;AACpC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=token-codec.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-codec.test.d.ts","sourceRoot":"","sources":["../../../src/tokens/__tests__/token-codec.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createTokenCodec, MIN_TOKEN_SECRET_LENGTH } from '../token-codec.js';
3
+ const SECRET = 'unit-test-secret-that-is-long-enough-to-pass';
4
+ describe('createTokenCodec', () => {
5
+ it('rejects a secret shorter than the minimum', () => {
6
+ expect(() => createTokenCodec({ prefix: 'share_', secret: 'short' })).toThrow(/at least 32 characters/);
7
+ expect(() => createTokenCodec({ prefix: 'share_', secret: 'x'.repeat(MIN_TOKEN_SECRET_LENGTH - 1) })).toThrow(/got 31/);
8
+ });
9
+ it('accepts a secret at exactly the minimum length', () => {
10
+ expect(() => createTokenCodec({ prefix: 'share_', secret: 'x'.repeat(MIN_TOKEN_SECRET_LENGTH) })).not.toThrow();
11
+ });
12
+ it('mints prefixed, unique, URL-safe tokens', () => {
13
+ const codec = createTokenCodec({ prefix: 'share_', secret: SECRET });
14
+ const a = codec.mintRawToken();
15
+ const b = codec.mintRawToken();
16
+ expect(a).toMatch(/^share_[A-Za-z0-9_-]{43}$/); // 32 bytes → 43 base64url chars
17
+ expect(a).not.toBe(b);
18
+ });
19
+ it('hashes deterministically, never echoing the raw token', () => {
20
+ const codec = createTokenCodec({ prefix: 'share_', secret: SECRET });
21
+ const raw = codec.mintRawToken();
22
+ const hash = codec.hashToken(raw);
23
+ expect(codec.hashToken(raw)).toBe(hash);
24
+ expect(hash).not.toContain(raw);
25
+ expect(hash).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
26
+ });
27
+ it('produces different hashes under different secrets (rotation invalidates)', () => {
28
+ const a = createTokenCodec({ prefix: 'share_', secret: SECRET });
29
+ const b = createTokenCodec({ prefix: 'share_', secret: SECRET + '-rotated' });
30
+ const raw = a.mintRawToken();
31
+ expect(a.hashToken(raw)).not.toBe(b.hashToken(raw));
32
+ });
33
+ it('hashesEqual: equal, unequal, and length-mismatch inputs', () => {
34
+ const codec = createTokenCodec({ prefix: 'invite_', secret: SECRET });
35
+ const hash = codec.hashToken(codec.mintRawToken());
36
+ const other = codec.hashToken(codec.mintRawToken());
37
+ expect(codec.hashesEqual(hash, hash)).toBe(true);
38
+ expect(codec.hashesEqual(hash, other)).toBe(false);
39
+ expect(codec.hashesEqual(hash, hash.slice(0, -1))).toBe(false);
40
+ });
41
+ it('looksLikeToken recognizes only its own prefix', () => {
42
+ const share = createTokenCodec({ prefix: 'share_', secret: SECRET });
43
+ const invite = createTokenCodec({ prefix: 'invite_', secret: SECRET });
44
+ const raw = share.mintRawToken();
45
+ expect(share.looksLikeToken(raw)).toBe(true);
46
+ expect(invite.looksLikeToken(raw)).toBe(false);
47
+ expect(share.looksLikeToken('random-string')).toBe(false);
48
+ });
49
+ });
50
+ //# sourceMappingURL=token-codec.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-codec.test.js","sourceRoot":"","sources":["../../../src/tokens/__tests__/token-codec.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export { createTokenCodec, MIN_TOKEN_SECRET_LENGTH, type TokenCodec, type TokenCodecConfig } from './token-codec.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tokens/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACN,gBAAgB,EAChB,uBAAuB,EACvB,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,3 @@
1
+ // HMAC token codec for capability-URL tokens (share links, invites, …).
2
+ export { createTokenCodec, MIN_TOKEN_SECRET_LENGTH } from './token-codec.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/tokens/index.ts"],"names":[],"mappings":"AAAA,wEAAwE;AAExE,OAAO,EACN,gBAAgB,EAChB,uBAAuB,EAGvB,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * HMAC token codec — the primitive behind capability-URL tokens (share links,
3
+ * invites, and anything shaped like them).
4
+ *
5
+ * ## Two-part design
6
+ *
7
+ * Each minted token involves TWO secrets:
8
+ *
9
+ * 1. **Per-token secret** — generated in `mintRawToken()` from 32 random
10
+ * bytes, returned to the caller exactly once at mint time. Different
11
+ * every token. This is what end-users carry in `?token=…`.
12
+ *
13
+ * 2. **Instance-wide HMAC secret** (`config.secret`) — used to hash tokens
14
+ * for storage. The store sees only `HMAC-SHA256(secret, token)`. A
15
+ * DB-only leak therefore can't be replayed: the attacker would need the
16
+ * secret too, and it lives in env, not the database.
17
+ *
18
+ * ## Format
19
+ * raw = `<prefix><base64url(32 random bytes)>`
20
+ * hash = base64url( HMAC-SHA256(secret, raw) )
21
+ *
22
+ * The resolution path hashes the supplied token with the same secret and looks
23
+ * up by exact match against the stored hash.
24
+ *
25
+ * Rotating the secret invalidates every token minted under it — give each
26
+ * token family (each prefix) its own secret so they rotate independently.
27
+ */
28
+ /**
29
+ * Minimum accepted secret length. Matches the "random ≥32-byte string"
30
+ * guidance everywhere these secrets are documented; enforced here so a
31
+ * short dev secret can't reach production silently (presence-only checks
32
+ * let a 4-char secret through).
33
+ */
34
+ export declare const MIN_TOKEN_SECRET_LENGTH = 32;
35
+ export interface TokenCodecConfig {
36
+ /** Token-family marker prepended to every raw token, e.g. `share_`. */
37
+ prefix: string;
38
+ /** Instance-wide HMAC secret. Must be at least 32 characters. */
39
+ secret: string;
40
+ }
41
+ export interface TokenCodec {
42
+ /** Mint: generate a fresh raw token. Show to the caller exactly once. */
43
+ mintRawToken(): string;
44
+ /** Hash a raw token to its store-side representation. */
45
+ hashToken(raw: string): string;
46
+ /** Constant-time equality for two stored hashes. */
47
+ hashesEqual(a: string, b: string): boolean;
48
+ /** Recognize this codec's token format on inbound requests. */
49
+ looksLikeToken(value: string): boolean;
50
+ /** The configured prefix, for callers that build URLs or error copy. */
51
+ readonly prefix: string;
52
+ }
53
+ export declare function createTokenCodec(config: TokenCodecConfig): TokenCodec;
54
+ //# sourceMappingURL=token-codec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-codec.d.ts","sourceRoot":"","sources":["../../src/tokens/token-codec.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C,MAAM,WAAW,gBAAgB;IAChC,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU;IAC1B,yEAAyE;IACzE,YAAY,IAAI,MAAM,CAAC;IACvB,yDAAyD;IACzD,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,oDAAoD;IACpD,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3C,+DAA+D;IAC/D,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CA2BrE"}
@@ -0,0 +1,62 @@
1
+ import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * HMAC token codec — the primitive behind capability-URL tokens (share links,
4
+ * invites, and anything shaped like them).
5
+ *
6
+ * ## Two-part design
7
+ *
8
+ * Each minted token involves TWO secrets:
9
+ *
10
+ * 1. **Per-token secret** — generated in `mintRawToken()` from 32 random
11
+ * bytes, returned to the caller exactly once at mint time. Different
12
+ * every token. This is what end-users carry in `?token=…`.
13
+ *
14
+ * 2. **Instance-wide HMAC secret** (`config.secret`) — used to hash tokens
15
+ * for storage. The store sees only `HMAC-SHA256(secret, token)`. A
16
+ * DB-only leak therefore can't be replayed: the attacker would need the
17
+ * secret too, and it lives in env, not the database.
18
+ *
19
+ * ## Format
20
+ * raw = `<prefix><base64url(32 random bytes)>`
21
+ * hash = base64url( HMAC-SHA256(secret, raw) )
22
+ *
23
+ * The resolution path hashes the supplied token with the same secret and looks
24
+ * up by exact match against the stored hash.
25
+ *
26
+ * Rotating the secret invalidates every token minted under it — give each
27
+ * token family (each prefix) its own secret so they rotate independently.
28
+ */
29
+ /**
30
+ * Minimum accepted secret length. Matches the "random ≥32-byte string"
31
+ * guidance everywhere these secrets are documented; enforced here so a
32
+ * short dev secret can't reach production silently (presence-only checks
33
+ * let a 4-char secret through).
34
+ */
35
+ export const MIN_TOKEN_SECRET_LENGTH = 32;
36
+ export function createTokenCodec(config) {
37
+ const { prefix, secret } = config;
38
+ if (secret.length < MIN_TOKEN_SECRET_LENGTH) {
39
+ throw new Error(`Secret for "${prefix}" tokens must be at least ${MIN_TOKEN_SECRET_LENGTH} characters ` +
40
+ `(got ${secret.length}). Generate one with: openssl rand -base64 32`);
41
+ }
42
+ return {
43
+ prefix,
44
+ mintRawToken() {
45
+ return prefix + randomBytes(32).toString('base64url');
46
+ },
47
+ hashToken(raw) {
48
+ return createHmac('sha256', secret).update(raw).digest('base64url');
49
+ },
50
+ hashesEqual(a, b) {
51
+ const ab = Buffer.from(a);
52
+ const bb = Buffer.from(b);
53
+ if (ab.length !== bb.length)
54
+ return false;
55
+ return timingSafeEqual(ab, bb);
56
+ },
57
+ looksLikeToken(value) {
58
+ return value.startsWith(prefix);
59
+ }
60
+ };
61
+ }
62
+ //# sourceMappingURL=token-codec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-codec.js","sourceRoot":"","sources":["../../src/tokens/token-codec.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAsB1C,MAAM,UAAU,gBAAgB,CAAC,MAAwB;IACxD,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAClC,IAAI,MAAM,CAAC,MAAM,GAAG,uBAAuB,EAAE,CAAC;QAC7C,MAAM,IAAI,KAAK,CACd,eAAe,MAAM,6BAA6B,uBAAuB,cAAc;YACtF,QAAQ,MAAM,CAAC,MAAM,+CAA+C,CACrE,CAAC;IACH,CAAC;IAED,OAAO;QACN,MAAM;QACN,YAAY;YACX,OAAO,MAAM,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QACvD,CAAC;QACD,SAAS,CAAC,GAAW;YACpB,OAAO,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;QACrE,CAAC;QACD,WAAW,CAAC,CAAS,EAAE,CAAS;YAC/B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC1B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC1B,IAAI,EAAE,CAAC,MAAM,KAAK,EAAE,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAC;YAC1C,OAAO,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QAChC,CAAC;QACD,cAAc,CAAC,KAAa;YAC3B,OAAO,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QACjC,CAAC;KACD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/server",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "Transport-agnostic compute/solve/definitions server building blocks for apps built on the Selva engine (limits, rate limiting, SSRF guard, input transform, solve pipeline, definition service + render loader)",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -34,6 +34,31 @@
34
34
  "source": "./src/providers/index.ts",
35
35
  "types": "./dist/providers/index.d.ts",
36
36
  "import": "./dist/providers/index.js"
37
+ },
38
+ "./tokens": {
39
+ "source": "./src/tokens/index.ts",
40
+ "types": "./dist/tokens/index.d.ts",
41
+ "import": "./dist/tokens/index.js"
42
+ },
43
+ "./errors": {
44
+ "source": "./src/errors/index.ts",
45
+ "types": "./dist/errors/index.d.ts",
46
+ "import": "./dist/errors/index.js"
47
+ },
48
+ "./http": {
49
+ "source": "./src/http/index.ts",
50
+ "types": "./dist/http/index.d.ts",
51
+ "import": "./dist/http/index.js"
52
+ },
53
+ "./access": {
54
+ "source": "./src/access/index.ts",
55
+ "types": "./dist/access/index.d.ts",
56
+ "import": "./dist/access/index.js"
57
+ },
58
+ "./ops": {
59
+ "source": "./src/ops/index.ts",
60
+ "types": "./dist/ops/index.d.ts",
61
+ "import": "./dist/ops/index.js"
37
62
  }
38
63
  },
39
64
  "files": [
@@ -41,10 +66,18 @@
41
66
  "src"
42
67
  ],
43
68
  "dependencies": {
44
- "@selvajs/compute": "^3.1.0-beta.3",
69
+ "@selvajs/compute": "^3.1.0-beta.5",
45
70
  "@selvajs/platform": "0.14.3-beta.0",
46
71
  "@selvajs/schemas": "4.7.0-beta.0"
47
72
  },
73
+ "peerDependencies": {
74
+ "@sentry/node": ">=8"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@sentry/node": {
78
+ "optional": true
79
+ }
80
+ },
48
81
  "devDependencies": {
49
82
  "@types/node": "^22.19.17",
50
83
  "typescript": "^5.9.3",
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import type {
3
+ Project,
4
+ ProjectAccessInput,
5
+ ProjectVisibility,
6
+ RequestContext
7
+ } from '@selvajs/platform';
8
+ import {
9
+ createProjectAccessInputBuilder,
10
+ type ProjectAccessFlags
11
+ } from '../project-access-input.js';
12
+
13
+ const CTX: RequestContext = {
14
+ userId: 'user-1',
15
+ actingOrgId: 'org-1',
16
+ platformPermissions: ['manage_compute'],
17
+ orgPermissions: ['manage_org_members']
18
+ } as RequestContext;
19
+
20
+ function project(visibility: ProjectVisibility): Project {
21
+ return {
22
+ id: 'proj-1',
23
+ orgId: 'org-1',
24
+ name: 'P',
25
+ slug: 'p',
26
+ visibility,
27
+ ownerId: 'user-9',
28
+ createdBy: 'user-9',
29
+ updatedBy: 'user-9',
30
+ autoJoinOnUpload: false,
31
+ createdAt: '2026-01-01T00:00:00Z',
32
+ updatedAt: '2026-01-01T00:00:00Z'
33
+ };
34
+ }
35
+
36
+ // Row identity is all the builder consumes — it passes rows through opaquely.
37
+ const MEMBER = {
38
+ projectId: 'proj-1',
39
+ userId: 'user-1',
40
+ role: 'editor'
41
+ } as unknown as NonNullable<ProjectAccessInput['member']>;
42
+ const ORG_MEMBER = {
43
+ orgId: 'org-1',
44
+ userId: 'user-1',
45
+ role: 'member'
46
+ } as unknown as NonNullable<ProjectAccessInput['orgMember']>;
47
+ const GRANT = {
48
+ projectId: 'proj-1',
49
+ orgId: 'org-2',
50
+ canSolve: true
51
+ } as unknown as ProjectAccessInput['platformGrants'][number];
52
+
53
+ function makeDeps(flags: Partial<ProjectAccessFlags> = {}) {
54
+ return {
55
+ getProjectMember: vi.fn().mockResolvedValue(MEMBER),
56
+ getOrgMember: vi.fn().mockResolvedValue(ORG_MEMBER),
57
+ listPlatformGrants: vi.fn().mockResolvedValue([GRANT]),
58
+ flags: vi.fn().mockReturnValue({
59
+ allowCrossOrgPublic: false,
60
+ enablePlatformProjects: false,
61
+ ...flags
62
+ })
63
+ };
64
+ }
65
+
66
+ describe('buildProjectAccessInput — per-visibility row fetching', () => {
67
+ it('private: fetches only the project member row', async () => {
68
+ const deps = makeDeps();
69
+ const builder = createProjectAccessInputBuilder(deps);
70
+ const input = await builder.buildProjectAccessInput(CTX, project('private'));
71
+
72
+ expect(input.member).toEqual(MEMBER);
73
+ expect(input.orgMember).toBeNull();
74
+ expect(input.platformGrants).toEqual([]);
75
+ expect(deps.getOrgMember).not.toHaveBeenCalled();
76
+ expect(deps.listPlatformGrants).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it.each(['org', 'public'] as const)('%s: fetches project member + org member', async (vis) => {
80
+ const deps = makeDeps();
81
+ const builder = createProjectAccessInputBuilder(deps);
82
+ const input = await builder.buildProjectAccessInput(CTX, project(vis));
83
+
84
+ expect(input.member).toEqual(MEMBER);
85
+ expect(input.orgMember).toEqual(ORG_MEMBER);
86
+ expect(deps.getOrgMember).toHaveBeenCalledWith(CTX, 'org-1', 'user-1');
87
+ });
88
+
89
+ it('public + ALLOW_CROSS_ORG_PUBLIC: skips the org member row', async () => {
90
+ const deps = makeDeps({ allowCrossOrgPublic: true });
91
+ const builder = createProjectAccessInputBuilder(deps);
92
+ const input = await builder.buildProjectAccessInput(CTX, project('public'));
93
+
94
+ expect(input.member).toEqual(MEMBER);
95
+ expect(input.orgMember).toBeNull();
96
+ expect(deps.getOrgMember).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('org visibility still fetches the org row when cross-org public is on', async () => {
100
+ const deps = makeDeps({ allowCrossOrgPublic: true });
101
+ const builder = createProjectAccessInputBuilder(deps);
102
+ const input = await builder.buildProjectAccessInput(CTX, project('org'));
103
+ expect(input.orgMember).toEqual(ORG_MEMBER);
104
+ });
105
+
106
+ it('platform + flag on: fetches grants only', async () => {
107
+ const deps = makeDeps({ enablePlatformProjects: true });
108
+ const builder = createProjectAccessInputBuilder(deps);
109
+ const input = await builder.buildProjectAccessInput(CTX, project('platform'));
110
+
111
+ expect(input.platformGrants).toEqual([GRANT]);
112
+ expect(input.member).toBeNull();
113
+ expect(deps.getProjectMember).not.toHaveBeenCalled();
114
+ expect(deps.getOrgMember).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('platform + flag off: skips the grant lookup entirely (feature disabled stays cheap)', async () => {
118
+ const deps = makeDeps({ enablePlatformProjects: false });
119
+ const builder = createProjectAccessInputBuilder(deps);
120
+ const input = await builder.buildProjectAccessInput(CTX, project('platform'));
121
+
122
+ expect(input.platformGrants).toEqual([]);
123
+ expect(deps.listPlatformGrants).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('threads ctx fields and flags into the input, with overrides winning', async () => {
127
+ const deps = makeDeps({ allowCrossOrgPublic: true });
128
+ const builder = createProjectAccessInputBuilder(deps);
129
+ const input = await builder.buildProjectAccessInput(CTX, project('private'), {
130
+ member: null
131
+ });
132
+
133
+ expect(input.userId).toBe('user-1');
134
+ expect(input.actingOrgId).toBe('org-1');
135
+ expect(input.orgPermissions).toEqual(CTX.orgPermissions);
136
+ expect(input.platformPermissions).toEqual(CTX.platformPermissions);
137
+ expect(input.allowCrossOrgPublic).toBe(true);
138
+ expect(input.member).toBeNull(); // override beat the fetched row
139
+ });
140
+
141
+ it('reads flags per call, not at builder creation', async () => {
142
+ const deps = makeDeps();
143
+ const builder = createProjectAccessInputBuilder(deps);
144
+ await builder.buildProjectAccessInput(CTX, project('private'));
145
+ deps.flags.mockReturnValue({ allowCrossOrgPublic: true, enablePlatformProjects: true });
146
+ const input = await builder.buildProjectAccessInput(CTX, project('private'));
147
+ expect(input.enablePlatformProjects).toBe(true);
148
+ });
149
+ });
150
+
151
+ describe('projectAccessInputFromRows', () => {
152
+ it('assembles from caller rows with zero I/O', () => {
153
+ const deps = makeDeps();
154
+ const builder = createProjectAccessInputBuilder(deps);
155
+ const input = builder.projectAccessInputFromRows(CTX, project('org'), {
156
+ member: MEMBER,
157
+ orgMember: ORG_MEMBER
158
+ });
159
+
160
+ expect(input.member).toEqual(MEMBER);
161
+ expect(input.orgMember).toEqual(ORG_MEMBER);
162
+ expect(input.platformGrants).toEqual([]);
163
+ expect(deps.getProjectMember).not.toHaveBeenCalled();
164
+ expect(deps.getOrgMember).not.toHaveBeenCalled();
165
+ expect(deps.listPlatformGrants).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('defaults omitted rows to null/empty', () => {
169
+ const builder = createProjectAccessInputBuilder(makeDeps());
170
+ const input = builder.projectAccessInputFromRows(CTX, project('private'), {});
171
+ expect(input.member).toBeNull();
172
+ expect(input.orgMember).toBeNull();
173
+ expect(input.platformGrants).toEqual([]);
174
+ });
175
+ });
@@ -0,0 +1,9 @@
1
+ // Access-rule input assembly — marshals providers + flags into what
2
+ // platform's pure canView/canEdit/… rules consume.
3
+
4
+ export {
5
+ createProjectAccessInputBuilder,
6
+ type ProjectAccessFlags,
7
+ type ProjectAccessInputBuilder,
8
+ type ProjectAccessInputDeps
9
+ } from './project-access-input.js';