Chunk 001: StreszCzarka---Krypto-AI-news-serwis
.github/dependabot.yml (lines 1–20)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/services/article-extractor"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
- package-ecosystem: "npm"
directory: "/services/storage-api"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
.github/workflows/ci.yml (lines 1–64)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
.github/workflows/ci.yml
name: CI
on:
push:
branches:
- main
- testy
- "chore/**"
- "feature/**"
- "fix/**"
pull_request:
branches:
- main
permissions:
contents: read
jobs:
quality:
name: Tests and quality checks
runs-on: ubuntu-latest
timeout-minutes: 15
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install article extractor dependencies
run: npm install --prefix services/article-extractor
- name: Install storage API dependencies
run: npm install --prefix services/storage-api
- name: Run syntax and automated tests
run: npm run ci
- name: Audit article extractor dependencies
run: npm audit --audit-level=high --prefix services/article-extractor
- name: Audit storage API dependencies
run: npm audit --audit-level=high --prefix services/storage-api
docker:
name: Docker builds
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build article extractor image
run: docker build --tag streszczarka-article-extractor:ci services/article-extractor
- name: Build storage API image
run: docker build --tag streszczarka-storage-api:ci services/storage-api
.gitignore (lines 1–7)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
.gitignore
.env
**/.env
node_modules/
**/node_modules/
frontend/index.html
.DS_Store
npm-debug.log*
.nvmrc (lines 1–1)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
.nvmrc
22
docker-compose.yml (lines 1–40)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
docker-compose.yml
services:
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- db-data:/var/lib/postgresql/data
- ./services/storage-api/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
storage-api:
build: ./services/storage-api
restart: unless-stopped
environment:
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_HOST: db
DB_PORT: 5432
PORT: 8900
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
ports:
- "127.0.0.1:${STORAGE_API_PORT:-8900}:8900"
depends_on:
- db
article-extractor:
build: ./services/article-extractor
restart: unless-stopped
environment:
PORT: 10420
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
ports:
- "127.0.0.1:${EXTRACTOR_PORT:-10420}:10420"
volumes:
db-data:
docs/SETUP.md (lines 1–133)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
docs/SETUP.md
# Setup
## 1. Lokalne usługi / VPS
Skopiuj konfigurację:
```bash
cp .env.example .env
```
Ustaw przede wszystkim:
```env
DB_PASSWORD=...
INTERNAL_API_KEY=...
```
Następnie:
```bash
docker compose up -d --build
```
Domyślnie usługi są wystawione tylko na `127.0.0.1`:
- storage API: `http://127.0.0.1:8900`
- article extractor: `http://127.0.0.1:10420`
Test:
```bash
curl http://127.0.0.1:10420/health
curl http://127.0.0.1:8900/health
```
## 2. Supabase
W SQL Editor uruchom:
```text
supabase/schema.sql
```
Powstaną tabele:
- `posts` — gotowe podsumowania,
- `dashboard` — dane rynkowe dla frontendu.
Frontend powinien korzystać tylko z publicznego `anon key`. Credential używany przez n8n do zapisu powinien być przechowywany w n8n i nie może trafić do repozytorium.
## 3. n8n
Zaimportuj:
1. `workflows/01-ingest-coindesk.json`
2. `workflows/02-ingest-cointelegraph.json`
3. `workflows/03-build-digest.json`
4. `workflows/04-market-dashboard.json` — opcjonalny
W n8n utwórz credentiale:
- **Mistral Cloud**,
- **Supabase**.
Workflowy portfolio używają `mistral-small-latest`. Mistral udostępnia Free mode z limitami, więc do demonstracyjnego uruchomienia nie trzeba od razu wracać do Mistral Large.
### Zmienne środowiskowe n8n
Workflowy nie zawierają adresu konkretnego VPS. Ustaw w środowisku kontenera/procesu n8n:
```env
STRESZCZARKA_STORAGE_API_URL=http://host.docker.internal:8900
STRESZCZARKA_EXTRACTOR_URL=http://host.docker.internal:10420
STRESZCZARKA_INTERNAL_API_KEY=ten-sam-klucz-co-w-.env
STRESZCZARKA_MARKETS_API_URL=https://twoj-serwis-rynkowy.example/api/markets
```
Jeżeli n8n działa bezpośrednio na hoście zamiast w Dockerze, użyj `http://127.0.0.1:8900` i `http://127.0.0.1:10420`.
Jeżeli instalacja n8n blokuje dostęp do `$env` w node'ach, zastąp te wyrażenia ręcznie adresami usług po imporcie workflowów.
## 4. Frontend
Frontend czyta `posts` i opcjonalnie `dashboard` z Supabase.
Lokalny build:
```bash
cd frontend
SUPABASE_URL=https://PROJECT.supabase.co \
SUPABASE_ANON_KEY=PUBLIC_ANON_KEY \
npm run build
```
Powstanie `frontend/index.html`.
### Render Static Site
Najprościej:
- Root Directory: `frontend`
- Build Command: `npm run build`
- Publish Directory: `.`
- Environment Variables: `SUPABASE_URL`, `SUPABASE_ANON_KEY`
## 5. Jak płyną dane
### Ingest
`RSS → deduplikacja po URL → Playwright → czyszczenie tekstu → Mistral → INFORMACJA/SZUM → PostgreSQL`
### Digest
`PostgreSQL.Artykuly → Mistral → gotowy digest → Supabase.posts → wyczyszczenie bufora Artykuly`
Tabela `Szum` pozostaje jako osobny zapis treści odfiltrowanych z głównego digestu.
### Dashboard
Opcjonalny workflow pobiera ceny i dane rynkowe z kilku źródeł i aktualizuje `Supabase.dashboard`.
## 6. Lockfile zależności
Obie usługi Node.js są niezależnymi pakietami. Po zmianie zależności wygeneruj i commituj ich lockfile osobno:
```bash
cd services/article-extractor
npm install
cd ../storage-api
npm install
```
Do repozytorium powinny trafić odpowiednie `package-lock.json`. Pozwala to później przejść w CI i Dockerfile z `npm install` na deterministyczne `npm ci`.
frontend/index.template.html (lines 1–101)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
frontend/index.template.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>StreszCzarka</title>
<style>
:root{font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#171717;background:#f5f5f2}
*{box-sizing:border-box} body{margin:0} main{max-width:980px;margin:0 auto;padding:48px 20px 80px}
h1{font-size:clamp(34px,6vw,64px);margin:0 0 8px;letter-spacing:-.04em} .lead{color:#5c5c5c;max-width:680px;line-height:1.6;margin:0 0 34px}
h2{margin:38px 0 14px;font-size:22px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}
.card,.post{background:white;border:1px solid #deded8;border-radius:14px;padding:16px}.card strong{display:block;margin-bottom:6px}.card span{color:#555}
.posts{display:grid;gap:14px}.post h3{margin:0 0 8px}.date{font-size:13px;color:#777;margin-bottom:12px}.content{white-space:pre-wrap;line-height:1.6}
.empty{color:#777;padding:18px 0}.footer{margin-top:42px;color:#888;font-size:13px}.status{font-size:13px;color:#777}
</style>
</head>
<body>
<main>
<h1>StreszCzarka</h1>
<p class="lead">Automatyczny przegląd informacji: źródła RSS → pobranie pełnej treści → streszczenie i klasyfikacja AI → zapis → okresowy digest.</p>
<div class="status" id="status">Ładowanie danych…</div>
<section id="dashboardSection" hidden>
<h2>Rynki</h2>
<div class="grid" id="dashboard"></div>
</section>
<section>
<h2>Podsumowania</h2>
<div class="posts" id="posts"></div>
</section>
<div class="footer">Dane publikacyjne są odczytywane z Supabase. Klucze zapisu pozostają po stronie automatyzacji.</div>
</main>
<script>
const SUPABASE_URL='__SUPABASE_URL__';
const SUPABASE_KEY='__SUPABASE_ANON_KEY__';
const headers={apikey:SUPABASE_KEY,Authorization:`Bearer ${SUPABASE_KEY}`};
function plainText(value){
const el=document.createElement('textarea');
el.innerHTML=String(value??'')
.replace(/<\/p>/gi,'\n\n')
.replace(/<br\s*\/?\s*>/gi,'\n')
.replace(/<hr\s*\/?\s*>/gi,'\n────────\n')
.replace(/<[^>]*>/g,'')
.replace(/^###\s*/gm,'');
return el.value.trim();
}
async function read(table,query=''){
const r=await fetch(`${SUPABASE_URL}/rest/v1/${table}?${query}`,{headers});
if(!r.ok) throw new Error(`${table}: ${r.status}`);
return r.json();
}
async function load(){
const statusEl=document.getElementById('status');
const postsEl=document.getElementById('posts');
let posts=[];
try{
posts=await read('posts','select=title,content,created_at&order=created_at.desc&limit=30');
}catch(e){
statusEl.textContent='Nie udało się pobrać podsumowań.';
postsEl.innerHTML='<div class="empty">Brak danych.</div>';
return;
}
postsEl.innerHTML='';
if(!posts.length) postsEl.innerHTML='<div class="empty">Brak opublikowanych podsumowań.</div>';
for(const post of posts){
const article=document.createElement('article');
article.className='post';
const h=document.createElement('h3'); h.textContent=post.title||'Podsumowanie';
const d=document.createElement('div'); d.className='date'; d.textContent=post.created_at?new Date(post.created_at).toLocaleString('pl-PL'):'';
const c=document.createElement('div'); c.className='content'; c.textContent=plainText(post.content);
article.append(h,d,c); postsEl.append(article);
}
try{
const dashboard=await read('dashboard','select=title,content&order=title.asc');
if(dashboard.length){
document.getElementById('dashboardSection').hidden=false;
const target=document.getElementById('dashboard');
target.innerHTML='';
dashboard.forEach(row=>{
const card=document.createElement('div'); card.className='card';
const name=document.createElement('strong'); name.textContent=row.title;
const value=document.createElement('span'); value.textContent=row.content??'—';
card.append(name,value); target.append(card);
});
}
}catch(_e){ /* dashboard is optional */ }
statusEl.textContent=`Ostatnia aktualizacja widoku: ${new Date().toLocaleString('pl-PL')}`;
}
load();
</script>
</body>
</html>
frontend/package.json (lines 1–8)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
frontend/package.json
{
"name": "streszczarka-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "node replace-env.js"
}
}
frontend/replace-env.js (lines 1–16)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
frontend/replace-env.js
const fs = require('fs');
for (const name of ['SUPABASE_URL', 'SUPABASE_ANON_KEY']) {
if (!process.env[name]) {
console.error(`Missing environment variable: ${name}`);
process.exit(1);
}
}
let html = fs.readFileSync('index.template.html', 'utf8');
html = html
.replaceAll('__SUPABASE_URL__', process.env.SUPABASE_URL)
.replaceAll('__SUPABASE_ANON_KEY__', process.env.SUPABASE_ANON_KEY);
fs.writeFileSync('index.html', html);
console.log('Built index.html');
package-lock.json (lines 1–12)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
package-lock.json
{
"name": "streszczarka-portfolio",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "streszczarka-portfolio",
"version": "1.0.0"
}
}
}
package.json (lines 1–13)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
package.json
{
"name": "streszczarka-portfolio",
"version": "1.0.0",
"private": true,
"scripts": {
"check:syntax": "node scripts/check-syntax.mjs",
"test:contracts": "node --test tests/*.test.mjs",
"test:article": "npm test --prefix services/article-extractor",
"test:storage": "npm test --prefix services/storage-api",
"test": "npm run test:contracts && npm run test:article && npm run test:storage",
"ci": "npm run check:syntax && npm test"
}
}
README.md (lines 1–81)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
README.md
# StreszCzarka
StreszCzarka to automatyczny system do monitorowania wiadomości branżowych i budowania krótkich podsumowań z użyciem AI.
Co kilka minut sprawdza źródła RSS, pobiera pełną treść nowych artykułów przez Playwright, usuwa duplikaty, streszcza tekst, oddziela **informację** od **szumu** i zapisuje wynik. Kilka razy dziennie z zebranych materiałów powstaje zbiorczy digest publikowany przez Supabase i prosty frontend.
```mermaid
flowchart LR
RSS[RSS: CoinDesk / Cointelegraph] --> N8N[n8n]
N8N --> PW[Playwright extractor]
PW --> AI[Mistral LLM]
AI --> CLASS[Informacja / Szum]
CLASS --> PG[(PostgreSQL)]
PG --> DIGEST[Digest AI]
DIGEST --> SB[(Supabase)]
SB --> WEB[Frontend]
```
## Technologie
- **n8n** — harmonogramy i orkiestracja procesu
- **Node.js / Express** — usługi API
- **Playwright** — pobieranie treści stron
- **Mistral AI** — streszczanie i klasyfikacja
- **PostgreSQL** — robocza baza artykułów, szumu i historii URL
- **Supabase** — publikacja gotowych podsumowań i danych dashboardu
- **Docker Compose** — uruchamianie usług na VPS
- **HTML / JavaScript** — lekki frontend
## Uruchomienie
1. Skopiuj `.env.example` do `.env` i ustaw hasło bazy oraz `INTERNAL_API_KEY`.
2. Uruchom usługi:
```bash
cp .env.example .env
docker compose up -d --build
```
3. W Supabase uruchom `supabase/schema.sql`.
4. Zaimportuj workflowy z katalogu `workflows/` do n8n i podepnij własne credentiale Mistral oraz Supabase.
5. Ustaw w środowisku n8n adresy usług opisane w `docs/SETUP.md`.
6. Frontend można wystawić jako prosty Static Site, np. na Render.
Dokładna konfiguracja: **[docs/SETUP.md](docs/SETUP.md)**.
## Zawartość repo
```text
workflows/ aktualne workflowy n8n
services/article-extractor/ Playwright → tekst artykułu
services/storage-api/ własne API + PostgreSQL
supabase/ schema publikacyjna
frontend/ statyczny frontend
```
## Status
To uporządkowana, portfolio-safe wersja działającego prototypu. Historycznie system działał na VPS, a frontend był serwowany osobno.
https://www.facebook.com/profile.php?id=61577122662784 - przez chwile projekt był też samoprowadzącym się funpagem
W tej wersji komponenty zostały zebrane w jedno repozytorium i oczyszczone z credentiali oraz prywatnych adresów infrastruktury.
## Automatyczna weryfikacja
Repozytorium zawiera GitHub Actions sprawdzające przy zmianach kodu:
- składnię JavaScript,
- testy bezpieczeństwa allowlisty URL i uwierzytelniania usług wewnętrznych,
- walidację identyfikatorów SQL używanych przez storage API,
- kontrakty eksportów n8n (poprawny JSON, brak przypiętych credentiali, zmienne środowiskowe),
- build statycznego frontendu z testowymi publicznymi wartościami Supabase,
- konfigurację Docker Compose (wiązanie usług do loopback),
- `npm audit --audit-level=high` dla obu usług Node.js,
- budowanie obu obrazów Docker.
`INTERNAL_API_KEY` jest wymagany przez obie usługi. Article extractor ponownie sprawdza allowlistę hostów dla każdego żądania wykonywanego przez przeglądarkę, dzięki czemu przekierowanie lub subrequest nie omija walidacji wejściowego URL.
> Uwaga: testy CI nie wykonują prawdziwych zapytań do Mistral, Supabase, n8n ani zewnętrznych serwisów newsowych. Weryfikują kod, kontrakty i budowanie komponentów bez używania prywatnych credentiali.
scripts/check-syntax.mjs (lines 1–27)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
scripts/check-syntax.mjs
import { readdirSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
const root = process.cwd();
const excluded = new Set(['node_modules', '.git']);
const files = [];
function walk(dir) {
for (const entry of readdirSync(dir)) {
if (excluded.has(entry)) continue;
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) walk(full);
else if (entry.endsWith('.js') || entry.endsWith('.mjs')) files.push(full);
}
}
walk(root);
for (const file of files) {
const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout);
process.exit(result.status || 1);
}
}
console.log(`Syntax OK: ${files.length} JavaScript files checked.`);
SECURITY.md (lines 1–19)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
SECURITY.md
# Security
Ta wersja repozytorium jest przygotowana do publicznego pokazania:
- nie zawiera kluczy API ani haseł,
- eksporty n8n nie zawierają przypiętych credentiali,
- adresy prywatnej infrastruktury zostały zastąpione zmiennymi środowiskowymi,
- `service_role` Supabase powinien istnieć wyłącznie w credentialach n8n / secret store,
- frontend używa wyłącznie publicznego `anon key` i ma tylko politykę odczytu RLS,
- wewnętrzne usługi mogą być chronione nagłówkiem `X-API-Key`.
Przed publicznym wdrożeniem warto dodatkowo ograniczyć porty firewallem i utrzymywać `storage-api` oraz `article-extractor` poza publicznym Internetem.
## Kontrole dodane w repozytorium
- `INTERNAL_API_KEY` jest konfiguracją wymaganą; brak klucza nie otwiera już endpointów `/api` ani `/extract`.
- Article extractor stosuje allowlistę nie tylko do URL wejściowego, ale również do kolejnych requestów wykonywanych przez Playwright.
- Testy automatyczne sprawdzają odrzucanie domen podobnych do dozwolonych oraz adresów loopback / metadata-service.
- CI uruchamia `npm audit --audit-level=high` osobno dla obu usług Node.js.
services/article-extractor/Dockerfile (lines 1–12)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/article-extractor/Dockerfile
FROM mcr.microsoft.com/playwright:v1.62.1-noble
WORKDIR /app
ENV NODE_ENV=production
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 10420
CMD ["npm", "start"]
services/article-extractor/index.js (lines 1–99)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/article-extractor/index.js
import express from 'express';
import { chromium } from 'playwright';
import {
createApiKeyGuard,
isAllowedBrowserRequest,
parseAllowedHosts,
validateTargetUrl
} from './lib/security.js';
const app = express();
app.use(express.json({ limit: '1mb' }));
const PORT = Number(process.env.PORT || 10420);
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || '';
const ALLOWED_HOSTS = parseAllowedHosts(process.env.ALLOWED_HOSTS);
if (!INTERNAL_API_KEY) {
console.error('Missing required environment variable: INTERNAL_API_KEY');
process.exit(1);
}
let browser;
const requireApiKey = createApiKeyGuard(INTERNAL_API_KEY);
async function getBrowser() {
if (!browser) {
browser = await chromium.launch({ headless: true });
}
return browser;
}
app.get('/health', (_req, res) => res.json({ ok: true }));
app.post('/extract', requireApiKey, async (req, res) => {
const { url } = req.body || {};
if (!url) return res.status(400).json({ error: 'Missing url' });
const validation = validateTargetUrl(url, ALLOWED_HOSTS);
if (!validation.ok) {
return res.status(validation.status).json({ error: validation.error });
}
const parsed = validation.url;
let context;
try {
const b = await getBrowser();
context = await b.newContext({
userAgent: 'Mozilla/5.0 (compatible; StreszCzarka/1.0; +portfolio)',
javaScriptEnabled: true
});
// Enforce the allowlist for every browser network request, not only the
// initial URL. This also blocks redirects or subrequests to unexpected hosts.
await context.route('**/*', async route => {
const requestUrl = route.request().url();
if (isAllowedBrowserRequest(requestUrl, ALLOWED_HOSTS)) {
return route.continue();
}
return route.abort('blockedbyclient');
});
const page = await context.newPage();
await page.goto(parsed.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(500);
const result = await page.evaluate(() => ({
title: document.querySelector('h1')?.innerText?.trim() || document.title?.trim() || '',
content: document.body?.innerText?.trim() || ''
}));
if (!result.content) {
return res.status(422).json({ error: 'No readable page content' });
}
res.json({
title: result.title,
content: result.content,
url: parsed.toString()
});
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Extraction failed' });
} finally {
if (context) await context.close().catch(() => {});
}
});
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`Article extractor listening on ${PORT}`);
});
async function shutdown() {
server.close();
if (browser) await browser.close().catch(() => {});
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
services/article-extractor/lib/security.js (lines 1–56)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/article-extractor/lib/security.js
export const DEFAULT_ALLOWED_HOSTS = [
'coindesk.com',
'cointelegraph.com',
'tradingeconomics.com',
'google.com'
];
export function parseAllowedHosts(value) {
if (!value) return [...DEFAULT_ALLOWED_HOSTS];
return String(value)
.split(',')
.map(host => host.trim().toLowerCase())
.filter(Boolean);
}
export function isAllowedHost(hostname, allowedHosts = DEFAULT_ALLOWED_HOSTS) {
const host = String(hostname || '').trim().toLowerCase();
return allowedHosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`));
}
export function validateTargetUrl(value, allowedHosts = DEFAULT_ALLOWED_HOSTS) {
let parsed;
try {
parsed = new URL(value);
} catch {
return { ok: false, status: 400, error: 'Invalid URL' };
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { ok: false, status: 400, error: 'Only http/https URLs are allowed' };
}
if (!isAllowedHost(parsed.hostname, allowedHosts)) {
return { ok: false, status: 403, error: `Host not allowed: ${parsed.hostname}` };
}
return { ok: true, url: parsed };
}
export function isAllowedBrowserRequest(value, allowedHosts = DEFAULT_ALLOWED_HOSTS) {
if (typeof value !== 'string') return false;
if (value.startsWith('data:') || value.startsWith('blob:')) return true;
return validateTargetUrl(value, allowedHosts).ok;
}
export function createApiKeyGuard(expectedKey) {
return function requireApiKey(req, res, next) {
if (!expectedKey) {
return res.status(503).json({ error: 'Internal API authentication is not configured' });
}
if (req.get('x-api-key') !== expectedKey) {
return res.status(401).json({ error: 'Unauthorized' });
}
return next();
};
}
services/article-extractor/package.json (lines 1–15)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/article-extractor/package.json
{
"name": "streszczarka-article-extractor",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js",
"test": "node --test tests/*.test.js",
"check:syntax": "node --check index.js && node --check lib/security.js"
},
"dependencies": {
"express": "^4.21.2",
"playwright": "1.62.1"
}
}
services/article-extractor/tests/security.test.js (lines 1–72)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/article-extractor/tests/security.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createApiKeyGuard,
isAllowedBrowserRequest,
isAllowedHost,
parseAllowedHosts,
validateTargetUrl
} from '../lib/security.js';
test('parseAllowedHosts normalizes configured hosts', () => {
assert.deepEqual(parseAllowedHosts(' CoinDesk.com, api.example.com ,, '), ['coindesk.com', 'api.example.com']);
});
test('host allowlist accepts exact host and subdomains', () => {
const allowed = ['coindesk.com'];
assert.equal(isAllowedHost('coindesk.com', allowed), true);
assert.equal(isAllowedHost('www.coindesk.com', allowed), true);
});
test('host allowlist rejects lookalike domains', () => {
const allowed = ['coindesk.com'];
assert.equal(isAllowedHost('coindesk.com.evil.example', allowed), false);
assert.equal(isAllowedHost('notcoindesk.com', allowed), false);
});
test('URL validation accepts only http/https on allowlisted hosts', () => {
const allowed = ['cointelegraph.com'];
assert.equal(validateTargetUrl('https://cointelegraph.com/news/test', allowed).ok, true);
assert.equal(validateTargetUrl('ftp://cointelegraph.com/file', allowed).status, 400);
assert.equal(validateTargetUrl('https://example.com/', allowed).status, 403);
assert.equal(validateTargetUrl('not-a-url', allowed).status, 400);
});
test('browser request policy re-checks redirects and subrequests', () => {
const allowed = ['coindesk.com'];
assert.equal(isAllowedBrowserRequest('https://www.coindesk.com/article', allowed), true);
assert.equal(isAllowedBrowserRequest('https://127.0.0.1/admin', allowed), false);
assert.equal(isAllowedBrowserRequest('http://169.254.169.254/latest/meta-data', allowed), false);
assert.equal(isAllowedBrowserRequest('data:text/plain,ok', allowed), true);
});
test('API key guard fails closed when key is not configured', () => {
const guard = createApiKeyGuard('');
const result = {};
const req = { get: () => undefined };
const res = {
status(code) { result.status = code; return this; },
json(body) { result.body = body; return this; }
};
let nextCalled = false;
guard(req, res, () => { nextCalled = true; });
assert.equal(result.status, 503);
assert.equal(nextCalled, false);
});
test('API key guard rejects wrong key and accepts correct key', () => {
const guard = createApiKeyGuard('secret');
const makeRes = result => ({
status(code) { result.status = code; return this; },
json(body) { result.body = body; return this; }
});
let nextCalled = false;
const rejected = {};
guard({ get: () => 'wrong' }, makeRes(rejected), () => { nextCalled = true; });
assert.equal(rejected.status, 401);
assert.equal(nextCalled, false);
guard({ get: () => 'secret' }, makeRes({}), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
services/storage-api/Dockerfile (lines 1–7)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 8900
CMD ["npm", "start"]
services/storage-api/index.js (lines 1–180)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/index.js
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const cron = require('node-cron');
const { createApiKeyGuard, normalizeTableName, safeField } = require('./lib/security');
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const PORT = Number(process.env.PORT || 8900);
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || '';
for (const name of ['DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_HOST', 'INTERNAL_API_KEY']) {
if (!process.env[name]) {
console.error(`Missing required environment variable: ${name}`);
process.exit(1);
}
}
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: Number(process.env.DB_PORT || 5432)
});
const requireApiKey = createApiKeyGuard(INTERNAL_API_KEY);
app.use('/api', requireApiKey);
async function connectWithRetry(retries = 15, delay = 1500) {
for (let i = 0; i < retries; i++) {
try {
await pool.query('SELECT 1');
return;
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
app.get('/health', async (_req, res) => {
try {
await pool.query('SELECT 1');
res.json({ ok: true });
} catch {
res.status(503).json({ ok: false });
}
});
app.get('/', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/api/tables', async (_req, res) => {
try {
const result = await pool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE'
ORDER BY table_name
`);
res.json(result.rows.map(row => row.table_name.toLowerCase()));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/:table', async (req, res) => {
try {
const table = normalizeTableName(req.params.table);
const result = await pool.query(`SELECT * FROM "${table}" ORDER BY id`);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/:table', async (req, res) => {
try {
const table = normalizeTableName(req.params.table);
const keys = Object.keys(req.body || {}).map(safeField);
if (!keys.length) {
const result = await pool.query(`INSERT INTO "${table}" DEFAULT VALUES RETURNING *`);
return res.json({ status: 'added', row: result.rows[0] });
}
const values = keys.map(key => req.body[key]);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(',');
const columns = keys.map(key => `"${key}"`).join(',');
const result = await pool.query(
`INSERT INTO "${table}" (${columns}) VALUES (${placeholders}) RETURNING *`,
values
);
res.json({ status: 'added', row: result.rows[0] });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.patch('/api/:table/update', async (req, res) => {
try {
const table = normalizeTableName(req.params.table);
const { id, field, value } = req.body || {};
if (!id || !field) return res.status(400).json({ error: 'Missing id or field' });
const column = safeField(field);
const result = await pool.query(
`UPDATE "${table}" SET "${column}"=$1 WHERE id=$2 RETURNING *`,
[value, id]
);
res.json(result.rowCount ? { status: 'updated', row: result.rows[0] } : { status: 'not_found' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/:table/clear', async (req, res) => {
try {
const table = normalizeTableName(req.params.table);
await pool.query(`DELETE FROM "${table}"`);
res.json({ status: 'cleared', table });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/tables/create', async (req, res) => {
try {
const { name, columns } = req.body || {};
if (!name || !Array.isArray(columns) || !columns.length) {
return res.status(400).json({ error: 'Missing table definition' });
}
const table = normalizeTableName(name);
const typeMap = { text: 'TEXT', int: 'INTEGER', date: 'DATE', bool: 'BOOLEAN' };
const defs = columns.map(column => `"${safeField(column.name)}" ${typeMap[column.type] || 'TEXT'}`);
await pool.query(`CREATE TABLE IF NOT EXISTS "${table}" (id SERIAL PRIMARY KEY, ${defs.join(',')})`);
res.json({ status: 'created', table });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/tables/:table', async (req, res) => {
try {
const table = normalizeTableName(req.params.table);
await pool.query(`DROP TABLE IF EXISTS "${table}" CASCADE`);
res.json({ status: 'dropped', table });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/:table/:id', async (req, res) => {
try {
const table = normalizeTableName(req.params.table);
const result = await pool.query(`DELETE FROM "${table}" WHERE id=$1 RETURNING *`, [req.params.id]);
res.json(result.rowCount ? { status: 'deleted', row: result.rows[0] } : { status: 'not_found' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
cron.schedule('0 0 * * *', async () => {
try {
await pool.query(`DELETE FROM "Archiwum" WHERE data_powstania < NOW() - INTERVAL '4 days'`);
} catch (error) {
console.error('Archive cleanup failed:', error.message);
}
});
connectWithRetry()
.then(() => app.listen(PORT, '0.0.0.0', () => console.log(`Storage API listening on ${PORT}`)))
.catch(error => {
console.error('Database unavailable:', error.message);
process.exit(1);
});
services/storage-api/init.sql (lines 1–22)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/init.sql
CREATE TABLE IF NOT EXISTS "Artykuly" (
id SERIAL PRIMARY KEY,
tytul TEXT NOT NULL,
tresc TEXT NOT NULL,
data_powstania TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "Szum" (
id SERIAL PRIMARY KEY,
tytul TEXT NOT NULL,
tresc TEXT NOT NULL,
data_powstania TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "Archiwum" (
id SERIAL PRIMARY KEY,
tytul TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
data_powstania TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_archiwum_data ON "Archiwum" (data_powstania);
services/storage-api/lib/security.js (lines 1–25)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/lib/security.js
function createApiKeyGuard(expectedKey) {
return function requireApiKey(req, res, next) {
if (!expectedKey) {
return res.status(503).json({ error: 'Internal API authentication is not configured' });
}
if (req.get('x-api-key') !== expectedKey) {
return res.status(401).json({ error: 'Unauthorized' });
}
return next();
};
}
function normalizeTableName(name) {
const safe = String(name || '');
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(safe)) throw new Error('Invalid table name');
return safe.charAt(0).toUpperCase() + safe.slice(1);
}
function safeField(name) {
const safe = String(name || '');
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(safe)) throw new Error('Invalid field name');
return safe;
}
module.exports = { createApiKeyGuard, normalizeTableName, safeField };
services/storage-api/package.json (lines 1–15)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/package.json
{
"name": "streszczarka-storage-api",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node index.js",
"test": "node --test tests/*.test.js",
"check:syntax": "node --check index.js && node --check lib/security.js"
},
"dependencies": {
"express": "^4.21.2",
"pg": "^8.16.3",
"node-cron": "^3.0.3"
}
}
services/storage-api/public/index.html (lines 1–57)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/public/index.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>StreszCzarka · baza</title>
<style>
body{font-family:system-ui,sans-serif;max-width:1200px;margin:0 auto;padding:24px;background:#f5f5f5;color:#222}
header{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}
select,button,input{font:inherit;padding:8px 10px}
.box{margin-top:18px;background:#fff;border:1px solid #ddd;border-radius:10px;overflow:auto}
table{width:100%;border-collapse:collapse;font-size:14px}
th,td{padding:9px;border-bottom:1px solid #eee;vertical-align:top;text-align:left;max-width:520px;white-space:pre-wrap}
th{position:sticky;top:0;background:#fafafa}
.muted{color:#666;font-size:13px}
</style>
</head>
<body>
<header>
<div><h1>StreszCzarka · baza</h1><div class="muted">Prosty podgląd wewnętrznego Postgresa.</div></div>
<div><select id="table"></select> <button id="refresh">Odśwież</button></div>
</header>
<div class="box" id="content">Ładowanie…</div>
<script>
const API_KEY_STORAGE='streszczarka_internal_api_key';
let apiKey=localStorage.getItem(API_KEY_STORAGE)||'';
async function api(path){
const headers={};
if(apiKey) headers['X-API-Key']=apiKey;
let r=await fetch(path,{headers});
if(r.status===401){
apiKey=prompt('Podaj INTERNAL_API_KEY')||'';
localStorage.setItem(API_KEY_STORAGE,apiKey);
r=await fetch(path,{headers:{'X-API-Key':apiKey}});
}
if(!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
}
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':'"',"'":'''}[c]));
async function loadTables(){
const tables=await api('/api/tables');
table.innerHTML=tables.map(t=>`<option>${esc(t)}</option>`).join('');
await loadData();
}
async function loadData(){
content.textContent='Ładowanie…';
const rows=await api(`/api/${encodeURIComponent(table.value)}`);
if(!rows.length){content.innerHTML='<p style="padding:16px">Brak rekordów.</p>';return;}
const keys=Object.keys(rows[0]);
content.innerHTML=`<table><thead><tr>${keys.map(k=>`<th>${esc(k)}</th>`).join('')}</tr></thead><tbody>${rows.map(row=>`<tr>${keys.map(k=>`<td>${esc(row[k])}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
}
table.addEventListener('change',loadData);
refresh.addEventListener('click',loadData);
loadTables().catch(e=>content.textContent=e.message);
</script>
</body>
</html>
services/storage-api/tests/security.test.js (lines 1–56)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
services/storage-api/tests/security.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { createApiKeyGuard, normalizeTableName, safeField } = require('../lib/security');
test('normalizeTableName maps API name to quoted application table name', () => {
assert.equal(normalizeTableName('artykuly'), 'Artykuly');
assert.equal(normalizeTableName('Archiwum'), 'Archiwum');
assert.equal(normalizeTableName('_tmp1'), '_tmp1');
});
test('normalizeTableName rejects SQL-like identifiers', () => {
for (const value of ['users;DROP TABLE users', 'bad-name', 'two words', '1table', '']) {
assert.throws(() => normalizeTableName(value), /Invalid table name/);
}
});
test('safeField accepts identifiers used as SQL columns', () => {
assert.equal(safeField('data_powstania'), 'data_powstania');
assert.equal(safeField('_internal2'), '_internal2');
});
test('safeField rejects unsafe column identifiers', () => {
for (const value of ['field-name', 'field name', 'x";DROP TABLE', '1field', '']) {
assert.throws(() => safeField(value), /Invalid field name/);
}
});
test('API key guard fails closed if configuration is missing', () => {
const guard = createApiKeyGuard('');
const result = {};
const res = {
status(code) { result.status = code; return this; },
json(body) { result.body = body; return this; }
};
let nextCalled = false;
guard({ get: () => undefined }, res, () => { nextCalled = true; });
assert.equal(result.status, 503);
assert.equal(nextCalled, false);
});
test('API key guard rejects invalid key and accepts valid key', () => {
const guard = createApiKeyGuard('internal-secret');
const makeRes = result => ({
status(code) { result.status = code; return this; },
json(body) { result.body = body; return this; }
});
const rejected = {};
let nextCalled = false;
guard({ get: () => 'wrong' }, makeRes(rejected), () => { nextCalled = true; });
assert.equal(rejected.status, 401);
assert.equal(nextCalled, false);
guard({ get: () => 'internal-secret' }, makeRes({}), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
supabase/schema.sql (lines 1–43)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
supabase/schema.sql
CREATE TABLE IF NOT EXISTS public.posts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.dashboard (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
content TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dashboard ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read posts" ON public.posts;
CREATE POLICY "public read posts"
ON public.posts FOR SELECT TO anon
USING (true);
DROP POLICY IF EXISTS "public read dashboard" ON public.dashboard;
CREATE POLICY "public read dashboard"
ON public.dashboard FOR SELECT TO anon
USING (true);
INSERT INTO public.dashboard (title, content) VALUES
('Gold', NULL),
('Silver', NULL),
('Platinum', NULL),
('Copper', NULL),
('Neodymium', NULL),
('Uranium', NULL),
('S&P 500', NULL),
('Nasdaq Composite', NULL),
('Dow Jones Industrial Average', NULL),
('DAX', NULL),
('Nikkei 225', NULL),
('Hang Seng Index', NULL),
('BTC/USD', NULL),
('US 10Y Bond', NULL)
ON CONFLICT (title) DO NOTHING;
tests/deployment-contracts.test.mjs (lines 1–22)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
tests/deployment-contracts.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const compose = readFileSync('docker-compose.yml', 'utf8');
const envExample = readFileSync('.env.example', 'utf8');
test('Docker Compose exposes internal APIs on loopback only', () => {
assert.match(compose, /127\.0\.0\.1:\$\{STORAGE_API_PORT:-8900\}:8900/);
assert.match(compose, /127\.0\.0\.1:\$\{EXTRACTOR_PORT:-10420\}:10420/);
});
test('both internal services receive the API key from environment', () => {
const matches = compose.match(/INTERNAL_API_KEY:\s*\$\{INTERNAL_API_KEY\}/g) || [];
assert.equal(matches.length, 2);
});
test('.env.example documents required local configuration without a real secret', () => {
assert.match(envExample, /^DB_PASSWORD=/m);
assert.match(envExample, /^INTERNAL_API_KEY=/m);
assert.doesNotMatch(envExample, /eyJ[A-Za-z0-9_-]{20,}\./);
});
tests/frontend-build.test.mjs (lines 1–32)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
tests/frontend-build.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
test('frontend build injects public Supabase configuration and removes placeholders', () => {
const temp = mkdtempSync(path.join(tmpdir(), 'streszczarka-frontend-'));
try {
cpSync(path.join(process.cwd(), 'frontend', 'index.template.html'), path.join(temp, 'index.template.html'));
cpSync(path.join(process.cwd(), 'frontend', 'replace-env.js'), path.join(temp, 'replace-env.js'));
const result = spawnSync(process.execPath, ['replace-env.js'], {
cwd: temp,
encoding: 'utf8',
env: {
...process.env,
SUPABASE_URL: 'https://portfolio-test.supabase.co',
SUPABASE_ANON_KEY: 'public-test-anon-key'
}
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const html = readFileSync(path.join(temp, 'index.html'), 'utf8');
assert.match(html, /https:\/\/portfolio-test\.supabase\.co/);
assert.match(html, /public-test-anon-key/);
assert.doesNotMatch(html, /__SUPABASE_URL__|__SUPABASE_ANON_KEY__/);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
tests/workflow-contracts.test.mjs (lines 1–52)
- Repository
- StreszCzarka---Krypto-AI-news-serwis
- Path
tests/workflow-contracts.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
const workflowDir = path.join(process.cwd(), 'workflows');
const files = readdirSync(workflowDir).filter(name => name.endsWith('.json')).sort();
function containsKey(value, key) {
if (Array.isArray(value)) return value.some(item => containsKey(item, key));
if (!value || typeof value !== 'object') return false;
if (Object.prototype.hasOwnProperty.call(value, key)) return true;
return Object.values(value).some(item => containsKey(item, key));
}
test('portfolio contains the four expected n8n workflows and every JSON parses', () => {
assert.deepEqual(files, [
'01-ingest-coindesk.json',
'02-ingest-cointelegraph.json',
'03-build-digest.json',
'04-market-dashboard.json'
]);
for (const file of files) {
assert.doesNotThrow(() => JSON.parse(readFileSync(path.join(workflowDir, file), 'utf8')), file);
}
});
test('exported workflows do not contain attached n8n credential objects', () => {
for (const file of files) {
const parsed = JSON.parse(readFileSync(path.join(workflowDir, file), 'utf8'));
assert.equal(containsKey(parsed, 'credentials'), false, `${file} contains credentials`);
}
});
test('workflow service calls use environment configuration instead of a fixed VPS address', () => {
const ingest1 = readFileSync(path.join(workflowDir, '01-ingest-coindesk.json'), 'utf8');
const ingest2 = readFileSync(path.join(workflowDir, '02-ingest-cointelegraph.json'), 'utf8');
const digest = readFileSync(path.join(workflowDir, '03-build-digest.json'), 'utf8');
for (const content of [ingest1, ingest2]) {
assert.match(content, /STRESZCZARKA_EXTRACTOR_URL/);
assert.match(content, /STRESZCZARKA_STORAGE_API_URL/);
assert.match(content, /STRESZCZARKA_INTERNAL_API_KEY/);
}
assert.match(digest, /STRESZCZARKA_STORAGE_API_URL/);
assert.match(digest, /STRESZCZARKA_INTERNAL_API_KEY/);
});
test('portfolio workflows use the documented Mistral model', () => {
const all = files.map(file => readFileSync(path.join(workflowDir, file), 'utf8')).join('\n');
assert.match(all, /mistral-small-latest/);
});