Compare commits

...

6 commits

22 changed files with 14783 additions and 65 deletions

6
.envrc Normal file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
if [[ $(type -t use_flake) != function ]]; then
echo "ERROR: direnv's use_flake function missing. update direnv to v2.30.0 or later." && exit 1
fi
use flake
dotenv

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.direnv
node_modules
result

18
CHANGELOG.md Normal file
View file

@ -0,0 +1,18 @@
# Changelog
## [Unreleased]
Changes yet to be released are documented here.
## v0.1.0
Initial release.
- `nix-cache` Worker serving Nix binary cache from Cloudflare R2
- JWT authentication via Keycloak OIDC (RS256, cached JWKS)
- Group-based authorization (`nix-cache-users` claim required)
- Bearer and Basic auth support (Basic for Nix netrc compatibility)
- Range request support (single byte ranges)
- Conditional request handling (etag, if-match, if-modified-since)
- Edge caching via Cloudflare Cache API
- Nix flake with checks (vitest in sandbox) and devShell

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# cloudflare-workers
Cloudflare Workers for Guardian Project infrastructure.
Canonical Repository: https://guardianproject.dev/ops/cloudflare-workers
## Workers
- **[nix-cache](nix-cache/)** — Nix binary cache proxy backed by Cloudflare R2 with JWT authentication
## Development
```bash
# enter dev environment
nix develop
# run all checks in the Nix sandbox
nix flake check
```
## Maintenance
This project is actively maintained by [Guardian Project](https://guardianproject.info).
### Issues
For bug reports and feature requests, please use the [Issues][issues] page.
### Security
For security-related issues, please contact us through our [security policy][sec].
[issues]: https://guardianproject.dev/ops/cloudflare-workers/issues
[sec]: https://guardianproject.info/contact/
## License
Copyright (c) 2026 Abel Luck <abel@guardianproject.info>
This project is licensed under the GNU General Public License v3.0 or later -
see the [LICENSE](LICENSE) file for details.

34
flake.lock generated
View file

@ -1,23 +1,5 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771848320,
@ -32,24 +14,8 @@
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs"
}
}

View file

@ -1,31 +1,58 @@
{
inputs = {
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; # tracks nixpkgs unstable branch
flake-parts.url = "github:hercules-ci/flake-parts";
};
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1";
outputs =
{ self, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit self; } {
flake = {
# Put your original flake attributes here.
};
{ self, nixpkgs }:
let
systems = [
# systems for which you want to build the `perSystem` attributes
"x86_64-linux"
"aarch64-darwin"
];
perSystem =
forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
in
{
config,
self',
inputs',
pkgs,
system,
...
}:
{
devShells = {
packages = forAllSystems (pkgs: {
nix-cache = pkgs.buildNpmPackage {
pname = "nix-cache";
version = "0.1.2";
src = ./nix-cache;
npmDepsHash = "sha256-bIujU7pZa1ExCZOMOoxsTeLDSF1GuPN/oMSgiMhY/04=";
buildPhase = ''
npx wrangler deploy --dry-run --outdir dist
'';
installPhase = ''
mkdir -p $out
cp dist/index.js $out/
'';
};
});
checks = forAllSystems (pkgs: {
nix-cache-tests = pkgs.buildNpmPackage {
pname = "nix-cache-tests";
version = "0.0.0";
src = ./nix-cache;
npmDepsHash = "sha256-bIujU7pZa1ExCZOMOoxsTeLDSF1GuPN/oMSgiMhY/04=";
dontBuild = true;
doCheck = true;
nativeBuildInputs = [ pkgs.patchelf ];
checkPhase = ''
runHook preCheck
binary=$(find node_modules -path '*/workerd-linux-64/bin/workerd' -type f)
if [ -n "$binary" ]; then
patchelf --set-interpreter "$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)" "$binary"
fi
npm test -- --run
runHook postCheck
'';
installPhase = ''
touch $out
'';
};
devShell = self.devShells.${pkgs.stdenv.hostPlatform.system}.default;
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
wrangler
@ -35,7 +62,6 @@
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
'';
};
};
};
});
};
}

12
nix-cache/.editorconfig Normal file
View file

@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

167
nix-cache/.gitignore vendored Normal file
View file

@ -0,0 +1,167 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.wrangler/

6
nix-cache/.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}

5
nix-cache/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}

34
nix-cache/AGENTS.md Normal file
View file

@ -0,0 +1,34 @@
# Cloudflare Workers
STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task.
## Docs
- https://developers.cloudflare.com/workers/
- MCP: `https://docs.mcp.cloudflare.com/mcp`
For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits`
## Commands
| Command | Purpose |
|---------|---------|
| `npx wrangler dev` | Local development |
| `npx wrangler deploy` | Deploy to Cloudflare |
| `npx wrangler types` | Generate TypeScript types |
Run `wrangler types` after changing bindings in wrangler.jsonc.
## Node.js Compatibility
https://developers.cloudflare.com/workers/runtime-apis/nodejs/
## Errors
- **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/`
- **All errors**: https://developers.cloudflare.com/workers/observability/errors/
## Product Docs
Retrieve API references and limits from:
`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`

27
nix-cache/README.md Normal file
View file

@ -0,0 +1,27 @@
# nix-cache
Serves a Nix binary cache from Cloudflare R2 with JWT-based authentication.
Only users with a valid Keycloak token and membership in the `nix-cache-users`
group can read from the cache.
Nix clients authenticate via netrc (Basic auth), while other clients can use
Bearer tokens directly. JWTs are verified locally using cached JWKS public keys.
## Development
```bash
npm install # install dependencies
npm test # run vitest (uses miniflare locally)
npm run dev # start wrangler dev server on localhost:8787
```
## Cloudflare Setup
1. Create an A record on the subdomain you want this Worker to run on which
points to `192.0.2.1`
2. Edit `wrangler.jsonc`:
- `route` should be the subdomain followed by `/*`
- `bucket_name` should be the name of the R2 bucket you'll use
3. Run `npx wrangler login` to login to Wrangler
4. Run `npm run deploy`
5. Upload an `index.html` to your bucket if you want a landing page

2651
nix-cache/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
nix-cache/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "nix-cache",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.12.4",
"@types/node": "^25.3.1",
"@types/range-parser": "^1.2.7",
"typescript": "^5.5.2",
"vitest": "~3.2.0",
"wrangler": "^4.68.1"
},
"dependencies": {
"jose": "^6.1.3",
"range-parser": "^1.2.1"
}
}

239
nix-cache/src/index.ts Normal file
View file

@ -0,0 +1,239 @@
import parseRange from 'range-parser';
import { createRemoteJWKSet, jwtVerify } from 'jose';
interface Env {
R2_BUCKET: R2Bucket;
CACHE_CONTROL: string;
KEYCLOAK_ISSUER: string;
KEYCLOAK_AUDIENCE: string;
REQUIRED_GROUP: string;
}
function hasBody(object: R2Object | R2ObjectBody): object is R2ObjectBody {
return (<R2ObjectBody>object).body !== undefined;
}
const resolvedKeyHeader = 'x-resolved-r2-key';
type Range = { offset: number; length?: number } | { offset?: number; length: number };
function rangeToHeader(range: Range | undefined, file: R2Object | R2ObjectBody): string {
if (range) {
const offset = range.offset ?? 0;
const length = range.length ?? 0;
return `bytes ${offset}-${offset + length - 1}/${file.size}`;
} else {
return '';
}
}
let JWKS: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJWKS(issuer: string) {
if (!JWKS) {
const url = new URL(`${issuer}/protocol/openid-connect/certs`);
JWKS = createRemoteJWKSet(url);
}
return JWKS;
}
async function authenticate(request: Request, env: Env): Promise<boolean> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return false;
let token: string | null = null;
if (authHeader.startsWith('Bearer ')) {
token = authHeader.slice(7);
} else if (authHeader.startsWith('Basic ')) {
try {
const decoded = atob(authHeader.slice(6));
const colon = decoded.indexOf(':');
token = colon !== -1 ? decoded.slice(colon + 1) : decoded;
} catch {
return false;
}
}
if (!token) return false;
try {
const jwks = getJWKS(env.KEYCLOAK_ISSUER);
const { payload } = await jwtVerify(token, jwks, {
issuer: env.KEYCLOAK_ISSUER,
audience: env.KEYCLOAK_AUDIENCE,
algorithms: ['RS256'],
});
const groups = payload.groups;
if (!Array.isArray(groups) || !groups.includes(env.REQUIRED_GROUP)) {
return false;
}
return true;
} catch {
return false;
}
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const allowedMethods = ['GET', 'HEAD', 'OPTIONS'];
if (allowedMethods.indexOf(request.method) === -1) return new Response('Method Not Allowed', { status: 405 });
if (request.method === 'OPTIONS') {
return new Response(null, { headers: { allow: allowedMethods.join(', ') } });
}
const url = new URL(request.url);
if (url.protocol !== 'https:') {
url.protocol = 'https:';
return Response.redirect(url.toString(), 301);
}
const authenticated = await authenticate(request, env);
if (!authenticated) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer realm="nix-cache"' },
});
}
if (url.pathname === '/') {
url.pathname = '/index.html';
}
const cache = caches.default;
let response = await cache.match(request);
let range: Range | undefined;
if (!response || !response.ok) {
console.warn('Cache miss');
const path = decodeURIComponent(url.pathname.substring(1));
let file: R2Object | R2ObjectBody | null | undefined;
// Range handling
if (request.method === 'GET') {
const rangeHeader = request.headers.get('range');
if (rangeHeader) {
file = await env.R2_BUCKET.head(path);
if (file === null)
return new Response('File Not Found', {
status: 404,
headers: {
[resolvedKeyHeader]: path,
},
});
const parsedRanges = parseRange(file.size, rangeHeader);
// R2 only supports 1 range at the moment, reject if there is more than one
if (parsedRanges !== -1 && parsedRanges !== -2 && parsedRanges.length === 1 && parsedRanges.type === 'bytes') {
let firstRange = parsedRanges[0];
range = {
offset: firstRange.start,
length: firstRange.end - firstRange.start + 1,
};
} else {
return new Response('Range Not Satisfiable', {
status: 416,
headers: {
[resolvedKeyHeader]: path,
},
});
}
}
}
// Etag/If-(Not)-Match handling
// R2 requires that etag checks must not contain quotes, and the S3 spec only allows one etag
// This silently ignores invalid or weak (W/) headers
const getHeaderEtag = (header: string | null) => header?.trim().replace(/^['"]|['"]$/g, '');
const ifMatch = getHeaderEtag(request.headers.get('if-match'));
const ifNoneMatch = getHeaderEtag(request.headers.get('if-none-match'));
const ifModifiedSince = Date.parse(request.headers.get('if-modified-since') || '');
const ifUnmodifiedSince = Date.parse(request.headers.get('if-unmodified-since') || '');
const ifRange = request.headers.get('if-range');
if (range && ifRange && file) {
const maybeDate = Date.parse(ifRange);
if (isNaN(maybeDate) || new Date(maybeDate) > file.uploaded) {
// httpEtag already has quotes, no need to use getHeaderEtag
if (ifRange !== file.httpEtag) range = undefined;
}
}
if (ifMatch || ifUnmodifiedSince) {
file = await env.R2_BUCKET.get(path, {
onlyIf: {
etagMatches: ifMatch,
uploadedBefore: ifUnmodifiedSince ? new Date(ifUnmodifiedSince) : undefined,
},
range,
});
if (file && !hasBody(file)) {
return new Response('Precondition Failed', {
status: 412,
headers: {
[resolvedKeyHeader]: path,
},
});
}
}
if (ifNoneMatch || ifModifiedSince) {
// if-none-match overrides if-modified-since completely
if (ifNoneMatch) {
file = await env.R2_BUCKET.get(path, { onlyIf: { etagDoesNotMatch: ifNoneMatch }, range });
} else if (ifModifiedSince) {
file = await env.R2_BUCKET.get(path, { onlyIf: { uploadedAfter: new Date(ifModifiedSince) }, range });
}
if (file && !hasBody(file)) {
return new Response(null, { status: 304 });
}
}
file =
request.method === 'HEAD'
? await env.R2_BUCKET.head(path)
: file && hasBody(file)
? file
: await env.R2_BUCKET.get(path, { range });
if (file === null) {
return new Response('File Not Found', {
status: 404,
headers: {
[resolvedKeyHeader]: path,
},
});
}
response = new Response(hasBody(file) ? file.body : null, {
status: (file?.size || 0) === 0 ? 204 : range ? 206 : 200,
headers: {
'accept-ranges': 'bytes',
etag: file.httpEtag,
'cache-control': file.httpMetadata.cacheControl ?? (env.CACHE_CONTROL || ''),
expires: file.httpMetadata.cacheExpiry?.toUTCString() ?? '',
'last-modified': file.uploaded.toUTCString(),
'content-encoding': file.httpMetadata?.contentEncoding ?? '',
'content-type': file.httpMetadata?.contentType ?? 'application/octet-stream',
'content-language': file.httpMetadata?.contentLanguage ?? '',
'content-disposition': file.httpMetadata?.contentDisposition ?? '',
'content-range': rangeToHeader(range, file),
[resolvedKeyHeader]: path,
},
});
}
if (request.method === 'GET' && !range) ctx.waitUntil(cache.put(request, response.clone()));
return response;
},
} satisfies ExportedHandler<Env>;

3
nix-cache/test/env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}

View file

@ -0,0 +1,159 @@
import { env, createExecutionContext, fetchMock } from 'cloudflare:test';
import { describe, it, expect, beforeAll } from 'vitest';
import { generateKeyPair, SignJWT, exportJWK } from 'jose';
import worker from '../src/index';
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
let privateKey: CryptoKey;
let jwksResponse: object;
beforeAll(async () => {
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
privateKey = pk;
const publicJwk = await exportJWK(publicKey);
publicJwk.alg = 'RS256';
publicJwk.use = 'sig';
publicJwk.kid = 'test-key';
jwksResponse = { keys: [publicJwk] };
fetchMock.activate();
fetchMock.disableNetConnect();
fetchMock
.get('https://id.guardianproject.info')
.intercept({ path: '/realms/gp/protocol/openid-connect/certs' })
.reply(200, JSON.stringify(jwksResponse), {
headers: { 'content-type': 'application/json' },
})
.persist();
});
async function makeJWT(overrides: Record<string, unknown> = {}): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const defaults = {
iss: env.KEYCLOAK_ISSUER,
aud: env.KEYCLOAK_AUDIENCE,
sub: 'test-user',
groups: [env.REQUIRED_GROUP],
iat: now,
exp: now + 3600,
};
const claims = { ...defaults, ...overrides };
let builder = new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' });
if (claims.iss !== undefined) builder = builder.setIssuer(claims.iss as string);
if (claims.aud !== undefined) builder = builder.setAudience(claims.aud as string);
if (claims.exp !== undefined) builder = builder.setExpirationTime(claims.exp as number);
return builder.sign(privateKey);
}
describe('nix-cache auth', () => {
it('HTTP request redirects to HTTPS', async () => {
const request = new IncomingRequest('http://example.com/nix-cache-info');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe('https://example.com/nix-cache-info');
});
it('OPTIONS without auth returns 200', async () => {
const request = new IncomingRequest('https://example.com/', { method: 'OPTIONS' });
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(200);
});
it('GET with no Authorization header returns 401', async () => {
const request = new IncomingRequest('https://example.com/nix-cache-info');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(401);
expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="nix-cache"');
});
it('GET with unrecognized auth scheme returns 401', async () => {
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: 'Token xyz' },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(401);
});
it('GET with Bearer + valid JWT passes auth', async () => {
const token = await makeJWT();
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: `Bearer ${token}` },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).not.toBe(401);
});
it('GET with Basic auth :token format passes auth', async () => {
const token = await makeJWT();
const basic = btoa(':' + token);
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: `Basic ${basic}` },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).not.toBe(401);
});
it('GET with Basic auth user:token format passes auth', async () => {
const token = await makeJWT();
const basic = btoa('user:' + token);
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: `Basic ${basic}` },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).not.toBe(401);
});
it('GET with invalid Bearer token returns 401', async () => {
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: 'Bearer garbage-token' },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(401);
});
it('GET with expired JWT returns 401', async () => {
const token = await makeJWT({ exp: Math.floor(Date.now() / 1000) - 3600 });
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: `Bearer ${token}` },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(401);
});
it('GET with JWT missing groups claim returns 401', async () => {
const token = await makeJWT({ groups: undefined });
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: `Bearer ${token}` },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(401);
});
it('GET with JWT where groups does not contain required group returns 401', async () => {
const token = await makeJWT({ groups: ['some-other-group'] });
const request = new IncomingRequest('https://example.com/nix-cache-info', {
headers: { Authorization: `Bearer ${token}` },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(401);
});
});

View file

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-pool-workers"]
},
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
"exclude": []
}

46
nix-cache/tsconfig.json Normal file
View file

@ -0,0 +1,46 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2024",
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
"lib": ["es2024"],
/* Specify what JSX code is generated. */
"jsx": "react-jsx",
/* Specify what module code is generated. */
"module": "es2022",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "Bundler",
/* Enable importing .json files */
"resolveJsonModule": true,
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
"allowJs": true,
/* Enable error reporting in type-checked JavaScript files. */
"checkJs": false,
/* Disable emitting files from a compilation. */
"noEmit": true,
/* Ensure that each file can be safely transpiled without relying on other imports. */
"isolatedModules": true,
/* Allow 'import x from y' when a module doesn't have a default export. */
"allowSyntheticDefaultImports": true,
/* Ensure that casing is correct in imports. */
"forceConsistentCasingInFileNames": true,
/* Enable all strict type-checking options. */
"strict": true,
/* Skip type checking all .d.ts files. */
"skipLibCheck": true,
"types": [
"./worker-configuration.d.ts",
"node"
]
},
"exclude": ["test"],
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
}

View file

@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
});

11214
nix-cache/worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

52
nix-cache/wrangler.jsonc Normal file
View file

@ -0,0 +1,52 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "nix-cache",
"main": "src/index.ts",
"compatibility_date": "2026-02-26",
"observability": {
"enabled": true
},
"compatibility_flags": [
"nodejs_compat"
],
"vars": {
"KEYCLOAK_ISSUER": "https://id.guardianproject.info/realms/gp",
"KEYCLOAK_AUDIENCE": "nix-cache",
"REQUIRED_GROUP": "nix-cache-users"
},
"r2_buckets": [
{ "binding": "R2_BUCKET", "bucket_name": "nix-cache" }
]
/**
* Smart Placement
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ]
}