Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ updates:
cooldown:
default-days: 7
semver-major-days: 14
ignore:
# The demo runs on Node 24, so its Node types stay on 24.x.
- dependency-name: '@types/node'
update-types: [version-update:semver-major]
groups:
runtime-dependencies:
dependency-type: production
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ describe('presenter console', () => {
it('has an inline script that parses', async () => {
const { app } = setup();
const scripts = [
...(await request(app).get('/')).text.matchAll(/<script>([\s\S]*?)<\/script>/g),
...(await request(app).get('/')).text.matchAll(/<script>([\s\S]*?)<\/script>/gi),
].map((m) => m[1] ?? '');
expect(scripts).toHaveLength(1);
for (const script of scripts) expect(() => new Function(script)).not.toThrow();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,15 @@ describe('GET /s2', () => {

it('asks these systems, same origin, never the portal: the portal has no CORS and the page must not need it', async () => {
const html = await page();
const script = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? '';
const script = /<script>([\s\S]*?)<\/script>/i.exec(html)?.[1] ?? '';
expect(script).toContain("fetch('/api/s2/forged-grant'");
expect(script).not.toMatch(/https?:\/\//);
expect(script).not.toContain('x-demo-persona');
});

it('keeps its inline script free of backticks, "${" and backslashes, since it lives in a template literal', async () => {
const html = await page();
const script = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? '';
const script = /<script>([\s\S]*?)<\/script>/i.exec(html)?.[1] ?? '';
expect(script.length).toBeGreaterThan(0);
expect(script).not.toContain('`');
expect(script).not.toContain('${');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1206,7 +1206,7 @@ describe('the S8 console page', () => {

it('has an inline script that compiles, and that only calls routes the legacy app serves', async () => {
const html = await page();
const script = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1];
const script = /<script>([\s\S]*?)<\/script>/i.exec(html)?.[1];
expect(script).toBeDefined();
// The script lives in a template literal: a backtick, "${" or backslash would have been changed
// on the way here.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ describe('the S5 console page', () => {
expect(res.text).toContain(label);
// The page's script lives in a template literal, where a stray "${" or backslash changes it
// silently: it must still parse.
const script = /<script>([\s\S]*?)<\/script>/.exec(res.text)?.[1] ?? '';
const script = /<script>([\s\S]*?)<\/script>/i.exec(res.text)?.[1] ?? '';
expect(script).not.toBe('');
expect(() => new Script(script)).not.toThrow();
});
Expand Down
7 changes: 4 additions & 3 deletions permit-portal-demo/apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"class-variance-authority": "0.7.1",
"cn": "0.4.0",
"express": "5.2.1",
"lucide-react": "1.51.0",
"express-rate-limit": "8.7.1",
"lucide-react": "1.52.0",
"permitio": "file:../../vendor/permitio-3.0.0-ffe1ccc.tgz",
"react": "19.3.0",
"react-dom": "19.3.0",
Expand All @@ -29,7 +30,7 @@
"@types/express": "5.0.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "5.2.0",
"vite": "7.3.6"
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.1"
}
}
19 changes: 19 additions & 0 deletions permit-portal-demo/apps/portal/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import express, { type ErrorRequestHandler, type Express, type RequestHandler } from 'express';
import { rateLimit } from 'express-rate-limit';
import type { DemoClock } from '#src/server/clock';
import type { PortalConfig } from '#src/server/config';
import { DeniedError, HttpError, permitUnreachable } from '#src/server/errors';
Expand Down Expand Up @@ -109,6 +110,14 @@ export interface Deps {
};
}

/**
* Requests one client address may make in a minute. The demo's own traffic shares one address
* (Docker shows the host's browser tabs and scenario runners as its gateway): a visible tab polls
* the decision panel every 0.7 s and its page every 5 to 10 s, and a runner polls as often as every
* 0.2 s. That peaks near 1,000 a minute; this leaves room for it and still stops a runaway loop.
*/
const REQUESTS_PER_MINUTE = 3_000;

/** Vite's output folder (apps/portal/dist/web). It may not exist yet: the API works without it. */
const WEB_DIR = fileURLToPath(new URL('../../dist/web/', import.meta.url));

Expand Down Expand Up @@ -137,6 +146,16 @@ export function createApp(deps: Deps, webDir: string = WEB_DIR): Express {
};
const app = express();
app.disable('x-powered-by');
// In front of every route, the SPA fallback too. One per app, so tests never share counts.
app.use(
rateLimit({
windowMs: 60_000,
limit: REQUESTS_PER_MINUTE,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Too many requests' },
}),
);
app.use(express.json());
app.get('/healthz', (_req, res) => {
res.sendStatus(200);
Expand Down
18 changes: 18 additions & 0 deletions permit-portal-demo/apps/portal/test/app-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,21 @@ describe('routing', () => {
).toEqual({ integrations: [] });
});
});

describe('rate limiting', () => {
it('limits every route, the SPA fallback included', async () => {
const { app } = harness();
for (const path of ['/healthz', '/api/meta', '/internal/nope', '/some/page']) {
const res = await request(app).get(path).set('x-demo-persona', 'sam');
expect(res.headers['ratelimit-policy'], path).toMatch(/q=3000; w=60/);
}
});

it('answers 429 in the API error shape once one client passes the limit', async () => {
const { app } = harness();
for (let i = 0; i < 3_000; i++) await request(app).get('/healthz');
const res = await request(app).get('/api/meta').set('x-demo-persona', 'sam');
expect(res.status).toBe(429);
expect(res.body).toEqual({ error: 'Too many requests' });
});
});
6 changes: 3 additions & 3 deletions permit-portal-demo/apps/portal/test/web-s5-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ const card = (scope: ScopeEntry[]) =>
),
);

/** The buttons a card offers, by their words. */
/** The buttons a card offers, by their words: each one's text, after its icon. */
const buttons = (html: string) =>
[...html.matchAll(/<button[^>]*>(.*?)<\/button>/g)].map((m) =>
(m[1] ?? '').replace(/<[^>]+>/g, ''),
[...html.matchAll(/<button[^>]*>(?:<svg[^>]*>.*?<\/svg>)?([^<]*)<\/button>/g)].map(
(m) => m[1] ?? '',
);

describe("Sam's integration card", () => {
Expand Down
4 changes: 2 additions & 2 deletions permit-portal-demo/apps/portal/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export default defineConfig({
outDir: '../../dist/web',
emptyOutDir: true,
// Third-party code in its own chunk keeps each chunk under Vite's 500 kB warning.
rollupOptions: {
output: { manualChunks: (id) => (id.includes('/node_modules/') ? 'vendor' : undefined) },
rolldownOptions: {
output: { codeSplitting: { groups: [{ name: 'vendor', test: /[\\/]node_modules[\\/]/ }] } },
},
},
// '/api/' with the slash: a bare '/api' prefix would also send the UI's own module /api.ts to the
Expand Down
2 changes: 1 addition & 1 deletion permit-portal-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"devDependencies": {
"@oxlint/plugins": "1.87.0",
"@types/node": "24.19.1",
"@types/supertest": "6.0.3",
"@types/supertest": "7.2.1",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"supertest": "7.3.1",
Expand Down
Loading
Loading