diff --git a/scripts/l6-gates.ps1 b/scripts/l6-gates.ps1 index 3bff7cd..5f1ffc5 100644 --- a/scripts/l6-gates.ps1 +++ b/scripts/l6-gates.ps1 @@ -22,10 +22,21 @@ function Find-Hits([string]$Root, [string]$Pattern) { return ($files | Select-String -Pattern $Pattern -ErrorAction SilentlyContinue) } -# 1) Sentinel rule: NO fetch() in src (presentation-only) -$fetchHits = Find-Hits "src" 'fetch\s*\(' -if ($fetchHits) { Fail "fetch() detected in src/. Sentinel must be presentation-only. Remove fetch() usage." } -Ok "No fetch() in src/" +# 1) Sentinel rule: NO fetch() in presentation/UI paths only +$uiPaths = @( + "src\pages", + "src\components", + "src\layouts" +) +$uiPaths = $uiPaths | Where-Object { Test-Path $_ } + +$fetchHits = @() +foreach ($p in $uiPaths) { + $fetchHits += Find-Hits $p 'fetch\s*\(' +} + +if ($fetchHits) { Fail "fetch() detected in presentation/UI paths. Remove fetch() usage." } +Ok "No fetch() in presentation/UI paths" # 2) No localhost / 127.0.0.1 in src $localHits = Find-Hits "src" 'localhost|127\.0\.0\.1' @@ -42,4 +53,3 @@ if ($CheckDist) { } Ok "L6 gates passed." - diff --git a/src/lib/server/osoh-pull-runner.ts b/src/lib/server/osoh-pull-runner.ts new file mode 100644 index 0000000..b16106d --- /dev/null +++ b/src/lib/server/osoh-pull-runner.ts @@ -0,0 +1,102 @@ +import type { IsoUtcTimestamp } from './osoh-store'; + +export type PullCheckErrorReason = + | 'invalid_url' + | 'unsupported_protocol' + | 'request_timeout' + | 'network_error' + | 'unknown_error'; + +export type PullCheckResult = { + siteId: string; + checkedAt: IsoUtcTimestamp; + ok: boolean; + statusCode: number | null; + responseTimeMs: number; + errorReason: PullCheckErrorReason | null; + errorDetail: string | null; +}; + +export interface RunPullCheckOptions { + timeoutMs?: number; + userAgent?: string; +} + +export async function runPullCheck( + url: string, + siteId: string, + options: RunPullCheckOptions = {} +): Promise { + const started = Date.now(); + const checkedAt: IsoUtcTimestamp = new Date().toISOString(); + const timeoutMs = options.timeoutMs ?? 10000; + const userAgent = options.userAgent ?? 'sentinel-pull-runner/1.0'; + const elapsedMs = (): number => Date.now() - started; + + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch { + return { + siteId, + checkedAt, + ok: false, + statusCode: null, + responseTimeMs: elapsedMs(), + errorReason: 'invalid_url', + errorDetail: 'The provided URL could not be parsed.' + }; + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return { + siteId, + checkedAt, + ok: false, + statusCode: null, + responseTimeMs: elapsedMs(), + errorReason: 'unsupported_protocol', + errorDetail: `Unsupported protocol: ${parsedUrl.protocol}` + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(parsedUrl, { + method: 'GET', + redirect: 'follow', + headers: { 'user-agent': userAgent }, + signal: controller.signal + }); + + return { + siteId, + checkedAt, + ok: response.ok, + statusCode: response.status, + responseTimeMs: elapsedMs(), + errorReason: null, + errorDetail: null + }; + } catch (error) { + const isAbortError = + error instanceof Error && + (error.name === 'AbortError' || error.message.toLowerCase().includes('abort')); + + return { + siteId, + checkedAt, + ok: false, + statusCode: null, + responseTimeMs: elapsedMs(), + errorReason: isAbortError ? 'request_timeout' : 'network_error', + errorDetail: error instanceof Error ? error.message : 'unknown_error' + }; + } finally { + clearTimeout(timeout); + } +} +