@motionstudies/data 0.1.0-alpha.28 → 0.1.0-alpha.29

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.
package/README.md CHANGED
@@ -200,6 +200,8 @@ The required `note` explains provenance. Optional `status`, `statusTone`, `clock
200
200
 
201
201
  `@motionstudies/data/recorder-evidence` reads explicit dated local journal/close-out and release targets, verifies their hashes and identities, and assesses missing expected outputs against deadlines. `@motionstudies/data/feed-incidents` persists debounced incident transitions with replay protection, atomic checksummed state, bounded retention and fail-closed capacity limits. These two modules require Node. See `docs/FEED-INCIDENTS.md` for CLI opt-in flags and operational limits; neither module installs a scheduler or sends notifications.
202
202
 
203
+ `@motionstudies/data/release` checks versioned release handoffs in Node or a browser: safe file descriptors (`{ path, sha256, bytes }`), every descriptor in a manifest whatever its layout, kind and schema, and fetched bytes through Web Crypto. `@motionstudies/data/release-files` opens a release directory in Node, optionally pinned to its manifest digest, reads only described files without escaping the directory, and verifies a whole release within a byte budget. Format meaning stays with each format's reader. See the source repository's `docs/RELEASE-CONTRACT.md`.
204
+
203
205
  ## Optional live airport feed
204
206
 
205
207
  `AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
@@ -1,6 +1,7 @@
1
1
  import type { SERVICE_TIMEZONE } from './uk-service-day.mjs'
2
2
  import type { FeedHealth, StageHealth } from './feed-observability.mjs'
3
3
  import type { DailyExpectation } from './feed-expectations.mjs'
4
+ export type ObserverCredential = string | { bearer?: string; accessClientId?: string; accessClientSecret?: string }
4
5
  export interface ObserverConfig {
5
6
  schemaVersion: 1
6
7
  kind: 'feed-observer-config'
@@ -23,7 +24,7 @@ export declare const OBSERVER_LIMITS: Readonly<{ requests: number; totalBytes: n
23
24
  export function observeFeeds(registry: unknown, config: unknown, options?: {
24
25
  fetchImpl?: typeof fetch
25
26
  clock?: () => number
26
- tokens?: { health?: string; consumers?: Record<string, string> }
27
+ tokens?: { health?: ObserverCredential; consumers?: Record<string, ObserverCredential> }
27
28
  limits?: typeof OBSERVER_LIMITS
28
29
  }): Promise<ObserverCheck>
29
30
  export function assessObserverCheck(registry: unknown, checked: ObserverCheck | null | undefined, options?: { now?: number; maxAgeSeconds?: number }): { state: 'healthy' | 'degraded' | 'unknown'; reasons: string[] }
package/feed-observer.mjs CHANGED
@@ -35,7 +35,12 @@ function client(fetchImpl, tokens, limits) {
35
35
  const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new CheckError('request-timeout')) }, Math.min(limits.timeoutMs, limits.totalMs - (performance.now() - began))) })
36
36
  try {
37
37
  return await Promise.race([timeout, (async () => {
38
- const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache', ...(token ? { Authorization: `Bearer ${token}` } : {}) }
38
+ const credential = typeof token === 'string' ? { bearer: token } : token ?? {}
39
+ const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache', ...(credential.bearer ? { Authorization: `Bearer ${credential.bearer}` } : {}) }
40
+ if (credential.accessClientId && credential.accessClientSecret) {
41
+ headers['CF-Access-Client-Id'] = credential.accessClientId
42
+ headers['CF-Access-Client-Secret'] = credential.accessClientSecret
43
+ } else if (credential.accessClientId || credential.accessClientSecret) throw new CheckError('credentials-invalid')
39
44
  const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: 'error', cache: 'no-store' })
40
45
  body = response.body
41
46
  if (!response.ok || response.redirected) throw new CheckError('http-unavailable')
@@ -6,7 +6,8 @@ export async function readOperationalFile(path, limit) {
6
6
  try {
7
7
  const stat = await handle.stat()
8
8
  if (!stat.isFile() || stat.size > limit) throw new Error('Input must be a bounded regular file')
9
- const bytes = Buffer.alloc(limit + 1)
9
+ // Size the buffer from the file, not the limit, so a generous limit costs nothing for small inputs; one spare byte detects growth.
10
+ const bytes = Buffer.alloc(Math.min(limit, stat.size) + 1)
10
11
  let size = 0
11
12
  while (size <= limit) {
12
13
  const { bytesRead } = await handle.read(bytes, size, bytes.length - size, null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/data",
3
- "version": "0.1.0-alpha.28",
3
+ "version": "0.1.0-alpha.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Offline data preparation primitives for Motion Studies.",
@@ -59,6 +59,14 @@
59
59
  "types": "./source-store.d.mts",
60
60
  "import": "./source-store.mjs"
61
61
  },
62
+ "./release": {
63
+ "types": "./release.d.mts",
64
+ "import": "./release.mjs"
65
+ },
66
+ "./release-files": {
67
+ "types": "./release-files.d.mts",
68
+ "import": "./release-files.mjs"
69
+ },
62
70
  "./uk-service-day": {
63
71
  "types": "./uk-service-day.d.mts",
64
72
  "import": "./uk-service-day.mjs"
@@ -125,7 +133,7 @@
125
133
  ],
126
134
  "sideEffects": false,
127
135
  "dependencies": {
128
- "@motionstudies/core": "0.1.0-alpha.28",
136
+ "@motionstudies/core": "0.1.0-alpha.29",
129
137
  "fast-xml-parser": "5.11.1"
130
138
  }
131
139
  }
@@ -10,5 +10,5 @@ export interface RecorderEvidencePlan {
10
10
  publication?: { pointerPath: string; operator: string; expectedThrough: string; deadlineAt: string }
11
11
  }[]
12
12
  }
13
- /** Reads explicitly selected local files, with integrity checks and a 64 MiB total read budget. */
13
+ /** Reads explicitly selected local files, with integrity checks, 256 MiB per journal and a 512 MiB total read budget. */
14
14
  export function recorderEvidenceHealth(registry: unknown, report: unknown, plan: unknown): Promise<FeedHealth>
@@ -47,7 +47,8 @@ const pending = (id, deadline, now, reason) => result(id, now > Date.parse(deadl
47
47
  export async function recorderEvidenceHealth(registryValue, reportValue, planValue) {
48
48
  const registry = readFeedRegistry(registryValue), report = readFeedHealth(reportValue, registry)
49
49
  const plan = readPlan(planValue, registry, report.producerId), now = Date.parse(report.observedAt)
50
- let remaining = 64 * 1024 * 1024
50
+ // A national bus day's journal is about 140 MiB; smaller feeds are a few MiB.
51
+ let remaining = 512 * 1024 * 1024
51
52
  const read = async (path, limit) => {
52
53
  if (remaining <= 0) throw new Error('Evidence byte budget exceeded')
53
54
  const bytes = await readOperationalFile(path, Math.min(limit, remaining))
@@ -74,7 +75,7 @@ export async function recorderEvidenceHealth(registryValue, reportValue, planVal
74
75
  }
75
76
 
76
77
  async function archive(plan, entry, read, now, skew) {
77
- const { bytes, json: journal } = await read(plan.journalPath, 16 * 1024 * 1024)
78
+ const { bytes, json: journal } = await read(plan.journalPath, 256 * 1024 * 1024)
78
79
  if (!journal.id || journal.config?.source !== entry.sourceId || !['complete', 'stopped'].includes(journal.status) || !Array.isArray(journal.attempts)) throw new Error('Invalid closed journal')
79
80
  const end = readOperationalTime(journal.plannedEnd), start = readOperationalTime(journal.startedAt)
80
81
  if (end !== plan.expectedEnd || Date.parse(end) < Date.parse(start) || Date.parse(end) > now || Date.parse(plan.deadlineAt) < Date.parse(end)) throw new Error('Invalid archive period/deadline')
@@ -0,0 +1,14 @@
1
+ import type { ReleaseDescriptor } from './release.mjs'
2
+ export interface ReleaseOpenOptions { kind?: string; schemaVersions?: readonly number[]; manifestSha256?: string; maxManifestBytes?: number; manifest?: string }
3
+ export interface OpenedRelease<M = Record<string, unknown>> {
4
+ manifest: M
5
+ manifestBytes: Buffer
6
+ descriptors: ReleaseDescriptor[]
7
+ /** Read a file the manifest describes, by path or descriptor, checking size and digest. */
8
+ read(file: string | ReleaseDescriptor, options?: { maxBytes?: number }): Promise<Buffer>
9
+ }
10
+ /** Bounded read without following a final symlink or blocking on a FIFO. */
11
+ export function readOperationalFile(path: string, limit: number): Promise<Buffer>
12
+ export function readReleaseFile(root: string, descriptor: ReleaseDescriptor, options?: { maxBytes?: number }): Promise<Buffer>
13
+ export function openRelease<M = Record<string, unknown>>(root: string, options?: ReleaseOpenOptions): Promise<OpenedRelease<M>>
14
+ export function verifyRelease<M = Record<string, unknown>>(root: string, options?: ReleaseOpenOptions & { maxTotalBytes?: number }): Promise<{ manifest: M; manifestSha256: string; files: number; bytes: number }>
@@ -0,0 +1,47 @@
1
+ import { realpath } from 'node:fs/promises'
2
+ import { join, relative, isAbsolute, resolve, sep } from 'node:path'
3
+ import { readOperationalFile } from './operational-files.mjs'
4
+ import { checkReleaseManifest, digestHex, isReleasePath, readDescriptor, releaseDescriptors, verifyDescriptorBytes } from './release.mjs'
5
+
6
+ export { readOperationalFile }
7
+ const inside = (root, path) => { const r = relative(root, path); return r !== '' && !r.startsWith(`..${sep}`) && r !== '..' && !isAbsolute(r) }
8
+
9
+ /** Read one described file from a release directory: safe path, no escape through links, bounded, size and digest checked. */
10
+ export async function readReleaseFile(root, descriptor, { maxBytes = 512 * 1024 ** 2 } = {}) {
11
+ const d = readDescriptor(descriptor)
12
+ if (d.bytes > maxBytes) throw new Error(`${d.path} exceeds the read limit`)
13
+ const base = await realpath(resolve(root)), actual = await realpath(join(base, ...d.path.split('/')))
14
+ if (!inside(base, actual)) throw new Error(`${d.path} resolves outside the release`)
15
+ return verifyDescriptorBytes(await readOperationalFile(actual, d.bytes), d)
16
+ }
17
+
18
+ /**
19
+ * Open a release directory: read `manifest.json` (bounded), optionally require its pinned digest, check its kind and
20
+ * schema, and enumerate its files. Nothing but the manifest is read until asked.
21
+ */
22
+ export async function openRelease(root, { kind, schemaVersions, manifestSha256, maxManifestBytes = 8 * 1024 ** 2, manifest: name = 'manifest.json' } = {}) {
23
+ if (!isReleasePath(name)) throw new Error('Unsafe manifest path')
24
+ const bytes = await readOperationalFile(join(resolve(root), name), maxManifestBytes)
25
+ if (manifestSha256 !== undefined && await digestHex(bytes) !== manifestSha256) throw new Error('Release manifest does not match its pinned digest')
26
+ const manifest = checkReleaseManifest(JSON.parse(bytes.toString('utf8')), { kind, schemaVersions })
27
+ const descriptors = releaseDescriptors(manifest), byPath = new Map(descriptors.map(d => [d.path, d]))
28
+ return {
29
+ manifest, manifestBytes: bytes, descriptors,
30
+ /** Read a described file by path or descriptor; a path the manifest does not list is refused. */
31
+ read: async (file, options) => {
32
+ const d = typeof file === 'string' ? byPath.get(file) : file
33
+ const listed = d && byPath.get(d.path)
34
+ if (!listed || listed.sha256 !== d.sha256 || listed.bytes !== d.bytes) throw new Error(`${typeof file === 'string' ? file : file?.path} is not described by the manifest`)
35
+ return readReleaseFile(root, d, options)
36
+ },
37
+ }
38
+ }
39
+
40
+ /** Verify every file a release describes against a total byte budget. */
41
+ export async function verifyRelease(root, { maxTotalBytes = 2 * 1024 ** 3, ...options } = {}) {
42
+ const release = await openRelease(root, options)
43
+ const total = release.descriptors.reduce((n, d) => n + d.bytes, 0)
44
+ if (total > maxTotalBytes) throw new Error('Release exceeds the verification budget')
45
+ for (const d of release.descriptors) await readReleaseFile(root, d)
46
+ return { manifest: release.manifest, manifestSha256: await digestHex(release.manifestBytes), files: release.descriptors.length, bytes: total }
47
+ }
package/release.d.mts ADDED
@@ -0,0 +1,10 @@
1
+ /** A file a release describes: its release-relative path and the size and SHA-256 of its stored bytes. */
2
+ export interface ReleaseDescriptor { path: string; sha256: string; bytes: number; [field: string]: unknown }
3
+ export declare const RELEASE_DIGEST: RegExp
4
+ export function isReleasePath(path: unknown): path is string
5
+ export function readDescriptor(value: unknown, label?: string): ReleaseDescriptor
6
+ /** Every descriptor in a manifest's `files` (array or map), `objects`, `chunks`, `list` and named top-level descriptors, sorted by path. */
7
+ export function releaseDescriptors(manifest: unknown): ReleaseDescriptor[]
8
+ export function checkReleaseManifest<T extends object>(manifest: T, options: { kind?: string; schemaVersions?: readonly number[] }): T
9
+ export function digestHex(bytes: ArrayBuffer | ArrayBufferView): Promise<string>
10
+ export function verifyDescriptorBytes<T extends ArrayBuffer | ArrayBufferView>(bytes: T, descriptor: ReleaseDescriptor): Promise<T>
package/release.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // Shared checks for versioned release handoffs. Runs in Node and in browsers (Web Crypto only).
2
+
3
+ export const RELEASE_DIGEST = /^[a-f0-9]{64}$/
4
+ const SEGMENT = /^[A-Za-z0-9._-]{1,255}$/
5
+
6
+ /** A release-relative path: forward-slash segments of safe characters, never absolute, empty, `.` or `..`. */
7
+ export function isReleasePath(path) {
8
+ return typeof path === 'string' && path.length <= 1024 && path.split('/').every(s => SEGMENT.test(s) && s !== '.' && s !== '..')
9
+ }
10
+
11
+ /** Validate one `{ path, sha256, bytes }` file descriptor; extra fields such as `decodedBytes` are kept. */
12
+ export function readDescriptor(value, label = 'file') {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label} descriptor`)
14
+ if (!isReleasePath(value.path)) throw new Error(`Unsafe ${label} path`)
15
+ if (!RELEASE_DIGEST.test(value.sha256 ?? '')) throw new Error(`Invalid ${label} digest`)
16
+ if (!Number.isSafeInteger(value.bytes) || value.bytes < 0) throw new Error(`Invalid ${label} size`)
17
+ return value
18
+ }
19
+
20
+ const looksLikeDescriptor = v => v && typeof v === 'object' && !Array.isArray(v) && typeof v.path === 'string' && 'sha256' in v && 'bytes' in v
21
+ /**
22
+ * Every file a manifest describes, whatever its layout: a `files` array or name-keyed map, an `objects` map keyed by
23
+ * digest, `chunks`, a published bus day's `list`, and named top-level descriptors such as `day` or `index`. The same
24
+ * path may appear more than once (air releases list chunks in `files` and `chunks`) but must then describe the same
25
+ * bytes.
26
+ */
27
+ export function releaseDescriptors(manifest) {
28
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Invalid release manifest')
29
+ const found = new Map()
30
+ const add = (value, label) => {
31
+ const d = readDescriptor(value, label), seen = found.get(d.path)
32
+ if (seen && (seen.sha256 !== d.sha256 || seen.bytes !== d.bytes)) throw new Error(`Conflicting descriptors for ${d.path}`)
33
+ if (!seen) found.set(d.path, d)
34
+ }
35
+ for (const [key, value] of Object.entries(manifest)) {
36
+ if (Array.isArray(value) && ['files', 'chunks', 'list'].includes(key)) value.forEach((v, i) => add(v, `${key}[${i}]`))
37
+ else if (['files', 'objects'].includes(key) && value && typeof value === 'object' && !Array.isArray(value)) for (const [name, v] of Object.entries(value)) add(v, `${key}.${name}`)
38
+ else if (looksLikeDescriptor(value)) add(value, key)
39
+ }
40
+ return [...found.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
41
+ }
42
+
43
+ /** Check a manifest's identity before trusting its layout. */
44
+ export function checkReleaseManifest(manifest, { kind, schemaVersions }) {
45
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Invalid release manifest')
46
+ if (kind !== undefined && manifest.kind !== kind) throw new Error(`Expected a ${kind} release`)
47
+ if (schemaVersions !== undefined && !schemaVersions.includes(manifest.schemaVersion)) throw new Error(`Unsupported ${manifest.kind ?? 'release'} schema version`)
48
+ return manifest
49
+ }
50
+
51
+ const hex = buffer => Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, '0')).join('')
52
+ /** SHA-256 of bytes as lowercase hex, through Web Crypto. */
53
+ export async function digestHex(bytes) {
54
+ return hex(await globalThis.crypto.subtle.digest('SHA-256', bytes))
55
+ }
56
+ /** Throw unless `bytes` are exactly the descriptor's size and digest. */
57
+ export async function verifyDescriptorBytes(bytes, descriptor) {
58
+ const d = readDescriptor(descriptor)
59
+ if (bytes.byteLength !== d.bytes) throw new Error(`${d.path} has ${bytes.byteLength} bytes, expected ${d.bytes}`)
60
+ if (await digestHex(bytes) !== d.sha256) throw new Error(`${d.path} does not match its digest`)
61
+ return bytes
62
+ }