@steve31415/baselib 1.0.0

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 ADDED
@@ -0,0 +1,14 @@
1
+ # baselib
2
+
3
+ The Plasticine new-world shared platform library (`@steve31415/baselib`).
4
+ What it provides and why: `docs/SPEC.md`. How it's put together:
5
+ `docs/IMPL.md`. Full design rationale with alternatives:
6
+ `~/migration/research/base-services-design.md` (step 5).
7
+
8
+ Modules (subpath exports): `config`, `log`, `log-browser`, `rum`, `auth`,
9
+ `s2s`, `db`, `http`.
10
+
11
+ Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
12
+ app runs it from `npm run verify`.
13
+
14
+ Reflects Plasticine Way commit: see `docs/IMPL.md` footer.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { Context } from 'hono';
2
+ import type { Logger } from './log-core.js';
3
+ import { ServiceCaller, S2sOptions } from './s2s.js';
4
+ export declare const DEFAULT_AUTH_URL = "https://auth.apps.snewman.net";
5
+ export declare const SESSION_COOKIE = "pw_session";
6
+ export interface AuthedUser {
7
+ type: 'user';
8
+ email: string;
9
+ name?: string;
10
+ userId?: string;
11
+ }
12
+ export type Identity = AuthedUser | ServiceCaller;
13
+ declare module 'hono' {
14
+ interface ContextVariableMap {
15
+ identity: Identity;
16
+ user: AuthedUser;
17
+ }
18
+ }
19
+ export interface RequireIdentityOptions extends S2sOptions {
20
+ /** Base URL of the auth service. */
21
+ authUrl?: string;
22
+ /** Injectable for tests (defaults to global fetch). */
23
+ whoamiFetch?: typeof fetch;
24
+ logger?: Logger;
25
+ /** Distinguishes API requests (401) from page requests (redirect).
26
+ * Default: path starts with /api/ or client prefers JSON. */
27
+ isApiRequest?: (c: Context) => boolean;
28
+ cacheTtl?: {
29
+ hitMs?: number;
30
+ missMs?: number;
31
+ };
32
+ }
33
+ export declare function requireIdentity(opts: RequireIdentityOptions): import("hono").MiddlewareHandler<any, string, {}, (Response & import("hono").TypedResponse<undefined, 302, "redirect">) | (Response & import("hono").TypedResponse<{
34
+ error: string;
35
+ }, 401 | 403 | 429, "json">) | (Response & import("hono").TypedResponse<{
36
+ error: string;
37
+ }, 503, "json">)>;
package/dist/auth.js ADDED
@@ -0,0 +1,102 @@
1
+ // Identity middleware for consuming apps (design §3.4).
2
+ //
3
+ // Resolves who is calling, in order:
4
+ // 1. Test bypass (AUTH_MODE=test): X-Test-S2S-Caller → service caller;
5
+ // X-Test-User header or TEST_USER env → user. Credential verification is
6
+ // bypassed; authorization (grants, app-level user checks) still runs.
7
+ // 2. Authorization: Bearer → service-to-service OIDC (s2s.ts).
8
+ // 3. pw_session cookie → whoami call to the auth service, cached
9
+ // in-instance (10 min hits / 30 s misses — the old-world numbers).
10
+ //
11
+ // Unauthenticated: APIs get 401 JSON, pages redirect to the auth service's
12
+ // login with a return URL. Auth-service *failures* (network, 5xx) return 503
13
+ // and never redirect — a redirect on failure loops forever (old-world lesson).
14
+ import { createHash } from 'node:crypto';
15
+ import { createMiddleware } from 'hono/factory';
16
+ import { authMode } from './config.js';
17
+ import { RateLimiter, resolveS2sCaller } from './s2s.js';
18
+ export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
19
+ export const SESSION_COOKIE = 'pw_session';
20
+ export function requireIdentity(opts) {
21
+ const authUrl = (opts.authUrl ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
22
+ const whoamiFetch = opts.whoamiFetch ?? fetch;
23
+ const hitMs = opts.cacheTtl?.hitMs ?? 600_000;
24
+ const missMs = opts.cacheTtl?.missMs ?? 30_000;
25
+ const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
26
+ const cache = new Map();
27
+ const isApi = opts.isApiRequest ??
28
+ ((c) => c.req.path.startsWith('/api/') || (c.req.header('accept') ?? '').includes('application/json'));
29
+ const unauthenticated = (c) => {
30
+ if (isApi(c))
31
+ return c.json({ error: 'not signed in' }, 401);
32
+ // Behind the LB the Node server sees plain HTTP; honor the proxy's
33
+ // protocol so the post-login redirect lands on https directly.
34
+ let returnUrl = c.req.url;
35
+ if (c.req.header('x-forwarded-proto') === 'https' && returnUrl.startsWith('http://')) {
36
+ returnUrl = `https://${returnUrl.slice('http://'.length)}`;
37
+ }
38
+ return c.redirect(`${authUrl}/login?redirect=${encodeURIComponent(returnUrl)}`);
39
+ };
40
+ return createMiddleware(async (c, next) => {
41
+ // Test-mode user bypass (service-caller test bypass is handled by
42
+ // resolveS2sCaller below, so both paths stay testable).
43
+ if (authMode() === 'test' && !c.req.header('x-test-s2s-caller')) {
44
+ const email = c.req.header('x-test-user') ?? process.env.TEST_USER;
45
+ if (!email)
46
+ return unauthenticated(c);
47
+ const user = { type: 'user', email };
48
+ c.set('identity', user);
49
+ c.set('user', user);
50
+ return next();
51
+ }
52
+ // Service-to-service path.
53
+ if (c.req.header('authorization') || c.req.header('x-test-s2s-caller')) {
54
+ const resolved = await resolveS2sCaller({ ...opts, limiter }, {
55
+ authorization: c.req.header('authorization'),
56
+ testCaller: c.req.header('x-test-s2s-caller'),
57
+ }, c.req.method, c.req.path);
58
+ if (!resolved.caller) {
59
+ const f = resolved.failure;
60
+ return c.json({ error: f.error }, f.status);
61
+ }
62
+ c.set('caller', resolved.caller);
63
+ c.set('identity', resolved.caller);
64
+ return next();
65
+ }
66
+ // Browser session path.
67
+ const cookieHeader = c.req.header('cookie') ?? '';
68
+ if (!/(?:^|;\s*)pw_session=/.test(cookieHeader))
69
+ return unauthenticated(c);
70
+ const key = createHash('sha256').update(cookieHeader).digest('hex');
71
+ const cached = cache.get(key);
72
+ if (cached && cached.expires > Date.now()) {
73
+ if (!cached.user)
74
+ return unauthenticated(c);
75
+ c.set('identity', cached.user);
76
+ c.set('user', cached.user);
77
+ return next();
78
+ }
79
+ let res;
80
+ try {
81
+ res = await whoamiFetch(`${authUrl}/api/whoami`, { headers: { cookie: cookieHeader } });
82
+ }
83
+ catch (err) {
84
+ opts.logger?.error('auth service unreachable', { error: err });
85
+ return c.json({ error: 'auth service unavailable' }, 503);
86
+ }
87
+ if (res.ok) {
88
+ const data = (await res.json());
89
+ const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id };
90
+ cache.set(key, { expires: Date.now() + hitMs, user });
91
+ c.set('identity', user);
92
+ c.set('user', user);
93
+ return next();
94
+ }
95
+ if (res.status === 401) {
96
+ cache.set(key, { expires: Date.now() + missMs });
97
+ return unauthenticated(c);
98
+ }
99
+ opts.logger?.error('auth service error', { meta_status: res.status });
100
+ return c.json({ error: 'auth service unavailable' }, 503);
101
+ });
102
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // `check-test-owners` — the npm bin every app runs from its verify script.
3
+ // All logic lives in ../test-owners.ts.
4
+ import { runTestOwnersCli } from '../test-owners.js';
5
+ process.exitCode = await runTestOwnersCli(process.argv.slice(2));
@@ -0,0 +1,4 @@
1
+ export declare function isCloudRun(): boolean;
2
+ export declare function requireEnv(name: string): string;
3
+ export declare function authMode(): 'test' | 'production';
4
+ export declare function assertBootSafety(): void;
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ // Environment/configuration accessors shared by all new-world services.
2
+ //
3
+ // Local dev and tests configure via plain env vars; Cloud Run injects env
4
+ // vars and Secret Manager values at deploy time. This module owns the
5
+ // local-vs-Cloud-Run distinction so nothing else has to look at K_SERVICE.
6
+ export function isCloudRun() {
7
+ return Boolean(process.env.K_SERVICE);
8
+ }
9
+ export function requireEnv(name) {
10
+ const v = process.env[name];
11
+ if (!v)
12
+ throw new Error(`required environment variable ${name} is not set`);
13
+ return v;
14
+ }
15
+ // 'test' enables the synthetic-identity bypass (X-Test-User /
16
+ // X-Test-S2S-Caller headers). Production images never set AUTH_MODE.
17
+ export function authMode() {
18
+ return process.env.AUTH_MODE === 'test' ? 'test' : 'production';
19
+ }
20
+ // Belt-and-suspenders: a container with the test bypass enabled must never
21
+ // boot in Cloud Run. Called by startServer(); call directly from any
22
+ // non-server entry point.
23
+ export function assertBootSafety() {
24
+ if (authMode() === 'test' && isCloudRun()) {
25
+ throw new Error('refusing to start: AUTH_MODE=test inside Cloud Run (K_SERVICE is set)');
26
+ }
27
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import pg from 'pg';
2
+ import type { Logger } from './log-core.js';
3
+ export interface PoolOptions {
4
+ /** Override the database name (default: env DB_NAME / DATABASE_URL path). */
5
+ database?: string;
6
+ max?: number;
7
+ }
8
+ export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
9
+ export declare function migrate(pool: pg.Pool, migrationsDir: string, logger?: Logger): Promise<{
10
+ applied: string[];
11
+ }>;
package/dist/db.js ADDED
@@ -0,0 +1,76 @@
1
+ // Database access. Local/dev/test: DATABASE_URL to a plain Postgres
2
+ // (per-checkout instance — see TESTING.md). Cloud Run: the Cloud SQL Node
3
+ // connector with IAM authentication (CLOUD_SQL_INSTANCE + DB_IAM_USER +
4
+ // DB_NAME env vars) — no database passwords anywhere in the serving path.
5
+ import { readdir, readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import pg from 'pg';
8
+ import { requireEnv } from './config.js';
9
+ // DATE columns come back as 'YYYY-MM-DD' strings, not JS Dates: calendar
10
+ // dates are timezone-free by meaning, and a Date object smears them across
11
+ // zones (and JSON-serializes to a full ISO timestamp). Set here — on the
12
+ // pg instance createPool actually uses — because apps that set it on their
13
+ // own pg copy miss this one (file:-dependency installs resolve separate pg
14
+ // instances).
15
+ pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
16
+ export async function createPool(opts = {}) {
17
+ const instance = process.env.CLOUD_SQL_INSTANCE;
18
+ if (instance) {
19
+ const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
20
+ const connector = new Connector();
21
+ const clientOpts = await connector.getOptions({
22
+ instanceConnectionName: instance,
23
+ ipType: IpAddressTypes.PUBLIC,
24
+ authType: AuthTypes.IAM,
25
+ });
26
+ return new pg.Pool({
27
+ ...clientOpts,
28
+ user: requireEnv('DB_IAM_USER'),
29
+ database: opts.database ?? requireEnv('DB_NAME'),
30
+ max: opts.max ?? 3, // shared instance: default max_connections is low
31
+ });
32
+ }
33
+ const url = new URL(requireEnv('DATABASE_URL'));
34
+ if (opts.database)
35
+ url.pathname = `/${opts.database}`;
36
+ return new pg.Pool({ connectionString: url.toString(), max: opts.max ?? 5 });
37
+ }
38
+ // Plain-SQL migrations: numbered files (001-*.sql, 002-*.sql, …) applied in
39
+ // order, recorded in schema_migrations, each inside a transaction, guarded by
40
+ // an advisory lock so concurrent boots can't race. Applied at service boot.
41
+ export async function migrate(pool, migrationsDir, logger) {
42
+ const files = (await readdir(migrationsDir)).filter((f) => f.endsWith('.sql')).sort();
43
+ const client = await pool.connect();
44
+ const applied = [];
45
+ try {
46
+ await client.query('select pg_advisory_lock(hashtext($1))', ['baselib_migrate']);
47
+ await client.query(`create table if not exists schema_migrations (
48
+ name text primary key,
49
+ applied_at timestamptz not null default now()
50
+ )`);
51
+ const { rows } = await client.query('select name from schema_migrations');
52
+ const done = new Set(rows.map((r) => r.name));
53
+ for (const file of files) {
54
+ if (done.has(file))
55
+ continue;
56
+ const sql = await readFile(join(migrationsDir, file), 'utf8');
57
+ try {
58
+ await client.query('begin');
59
+ await client.query(sql);
60
+ await client.query('insert into schema_migrations (name) values ($1)', [file]);
61
+ await client.query('commit');
62
+ }
63
+ catch (err) {
64
+ await client.query('rollback');
65
+ throw new Error(`migration ${file} failed`, { cause: err });
66
+ }
67
+ applied.push(file);
68
+ logger?.info('migration applied', { migration: file });
69
+ }
70
+ }
71
+ finally {
72
+ await client.query('select pg_advisory_unlock(hashtext($1))', ['baselib_migrate']).catch(() => { });
73
+ client.release();
74
+ }
75
+ return { applied };
76
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { Hono, Context } from 'hono';
2
+ import { serve } from '@hono/node-server';
3
+ import type { Logger } from './log-core.js';
4
+ /** One INFO line per request on exit (method, path, status, elapsed,
5
+ * identity); /health excluded. Entry-point detail beyond this (key
6
+ * parameters, outcomes) is the handler's job per OPERATIONS.md. */
7
+ export declare function requestLogging(logger: Logger): import("hono").MiddlewareHandler<any, string, {}, Response>;
8
+ export declare function addHealth(app: Hono): void;
9
+ /** app.onError handler: log with full context, return a plain 500. */
10
+ export declare function errorHandler(logger: Logger): (err: Error, c: Context) => (Response & import("hono").TypedResponse<{
11
+ error: string;
12
+ detail: string;
13
+ }, 500, "json">) | (Response & import("hono").TypedResponse<`Internal error: ${string}`, 500, "text">);
14
+ export interface StartServerOptions {
15
+ app: Hono;
16
+ logger: Logger;
17
+ port?: number;
18
+ }
19
+ /** Standard entry point: safety check, serve, graceful SIGTERM shutdown
20
+ * (stop accepting, then flush all loggers, then exit). Returns the Node
21
+ * server so callers can attach protocol upgrades (e.g. @hono/node-ws's
22
+ * injectWebSocket). */
23
+ export declare function startServer(opts: StartServerOptions): ReturnType<typeof serve>;
package/dist/http.js ADDED
@@ -0,0 +1,69 @@
1
+ // HTTP service plumbing: request logging, health endpoint, error handler,
2
+ // and the standard server entry point with graceful shutdown.
3
+ import { createMiddleware } from 'hono/factory';
4
+ import { serve } from '@hono/node-server';
5
+ import { assertBootSafety } from './config.js';
6
+ import { flushAllLoggers } from './log.js';
7
+ import { truncate } from './log-core.js';
8
+ /** One INFO line per request on exit (method, path, status, elapsed,
9
+ * identity); /health excluded. Entry-point detail beyond this (key
10
+ * parameters, outcomes) is the handler's job per OPERATIONS.md. */
11
+ export function requestLogging(logger) {
12
+ return createMiddleware(async (c, next) => {
13
+ if (c.req.path === '/health')
14
+ return next();
15
+ const started = Date.now();
16
+ await next();
17
+ const identity = c.var.identity;
18
+ logger.info(`${c.req.method} ${c.req.path} ${c.res.status}`, {
19
+ method: c.req.method,
20
+ path: truncate(c.req.path, 200),
21
+ status: c.res.status,
22
+ elapsed_ms: Date.now() - started,
23
+ ...(identity ? { identity: identity.email } : {}),
24
+ });
25
+ });
26
+ }
27
+ export function addHealth(app) {
28
+ app.get('/health', (c) => c.json({
29
+ ok: true,
30
+ service: process.env.K_SERVICE ?? 'local',
31
+ revision: process.env.K_REVISION ?? 'local',
32
+ }));
33
+ }
34
+ /** app.onError handler: log with full context, return a plain 500. */
35
+ export function errorHandler(logger) {
36
+ return (err, c) => {
37
+ logger.error(`unhandled error in ${c.req.method} ${c.req.path}`, {
38
+ error: err,
39
+ method: c.req.method,
40
+ path: c.req.path,
41
+ });
42
+ const wantsJson = c.req.path.startsWith('/api/') || (c.req.header('accept') ?? '').includes('application/json');
43
+ return wantsJson
44
+ ? c.json({ error: 'internal error', detail: String(err.message ?? err) }, 500)
45
+ : c.text(`Internal error: ${String(err.message ?? err)}`, 500);
46
+ };
47
+ }
48
+ /** Standard entry point: safety check, serve, graceful SIGTERM shutdown
49
+ * (stop accepting, then flush all loggers, then exit). Returns the Node
50
+ * server so callers can attach protocol upgrades (e.g. @hono/node-ws's
51
+ * injectWebSocket). */
52
+ export function startServer(opts) {
53
+ assertBootSafety();
54
+ const port = opts.port ?? Number(process.env.PORT ?? 8080);
55
+ const server = serve({ fetch: opts.app.fetch, port }, (info) => {
56
+ opts.logger.info('server started', { port: info.port });
57
+ });
58
+ process.once('SIGTERM', () => {
59
+ opts.logger.info('SIGTERM received, shutting down');
60
+ server.close(() => {
61
+ void flushAllLoggers().finally(() => process.exit(0));
62
+ });
63
+ // Cloud Run's grace period is 10s; don't rely on every socket closing.
64
+ setTimeout(() => {
65
+ void flushAllLoggers().finally(() => process.exit(0));
66
+ }, 5000).unref();
67
+ });
68
+ return server;
69
+ }
@@ -0,0 +1,6 @@
1
+ export * from './config.js';
2
+ export * from './log.js';
3
+ export * from './s2s.js';
4
+ export * from './auth.js';
5
+ export * from './db.js';
6
+ export * from './http.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from './config.js';
2
+ export * from './log.js';
3
+ export * from './s2s.js';
4
+ export * from './auth.js';
5
+ export * from './db.js';
6
+ export * from './http.js';
@@ -0,0 +1,15 @@
1
+ import { Logger } from './log-core.js';
2
+ export { serializeError } from './log-core.js';
3
+ export type { Logger } from './log-core.js';
4
+ export interface BrowserLoggerOptions {
5
+ app?: string;
6
+ dataset?: string;
7
+ token?: string;
8
+ baseUrl?: string;
9
+ context?: Record<string, unknown>;
10
+ /** Also mirror events to the devtools console (default false). */
11
+ console?: boolean;
12
+ }
13
+ export declare function createBrowserLogger(opts?: BrowserLoggerOptions): Logger;
14
+ /** Route uncaught errors and unhandled rejections into the logger. */
15
+ export declare function installGlobalErrorHandlers(logger: Logger): void;
@@ -0,0 +1,69 @@
1
+ // Browser logger: same event shape and API as the server logger, shipping
2
+ // directly to Axiom (CORS is wildcard-open on the ingest endpoint —
3
+ // verified). Configuration comes from meta tags so server templates can
4
+ // inject it:
5
+ //
6
+ // <meta name="pw-log-app" content="auth">
7
+ // <meta name="pw-log-dataset" content="apps">
8
+ // <meta name="pw-log-token" content="...ingest-only, dataset-scoped...">
9
+ //
10
+ // The token is ingest-only and dataset-scoped by design — visible to any
11
+ // signed-in user, able to do nothing but add log events.
12
+ import { LogShipper, makeEvent } from './log-core.js';
13
+ export { serializeError } from './log-core.js';
14
+ function metaContent(name) {
15
+ return document.querySelector(`meta[name="${name}"]`)?.content || undefined;
16
+ }
17
+ export function createBrowserLogger(opts = {}) {
18
+ const app = opts.app ?? metaContent('pw-log-app') ?? 'unknown';
19
+ const dataset = opts.dataset ?? metaContent('pw-log-dataset') ?? 'apps';
20
+ const token = opts.token ?? metaContent('pw-log-token');
21
+ const baseUrl = opts.baseUrl ?? 'https://api.axiom.co';
22
+ const shipper = token
23
+ ? new LogShipper({
24
+ url: `${baseUrl}/v1/datasets/${dataset}/ingest`,
25
+ token,
26
+ batchSize: 20,
27
+ // keepalive lets a flush started during page unload complete.
28
+ fetchInit: { keepalive: true },
29
+ })
30
+ : undefined;
31
+ function build(context) {
32
+ const factory = { app, source: 'browser', context };
33
+ const emit = (level, message, meta) => {
34
+ const event = makeEvent(factory, level, message, {
35
+ ...meta,
36
+ page: location.pathname,
37
+ });
38
+ if (opts.console)
39
+ console[level === 'debug' ? 'debug' : level === 'error' ? 'error' : 'log'](message, meta ?? '');
40
+ shipper?.enqueue(event);
41
+ };
42
+ return {
43
+ debug: (m, meta) => emit('debug', m, meta),
44
+ info: (m, meta) => emit('info', m, meta),
45
+ warn: (m, meta) => emit('warn', m, meta),
46
+ error: (m, meta) => emit('error', m, meta),
47
+ with: (extra) => build({ ...context, ...extra }),
48
+ flush: () => shipper?.flush() ?? Promise.resolve(),
49
+ };
50
+ }
51
+ const logger = build(opts.context);
52
+ // Ship what's pending whenever the page may be going away.
53
+ const flushNow = () => void logger.flush();
54
+ addEventListener('pagehide', flushNow);
55
+ document.addEventListener('visibilitychange', () => {
56
+ if (document.visibilityState === 'hidden')
57
+ flushNow();
58
+ });
59
+ return logger;
60
+ }
61
+ /** Route uncaught errors and unhandled rejections into the logger. */
62
+ export function installGlobalErrorHandlers(logger) {
63
+ addEventListener('error', (e) => {
64
+ logger.error('uncaught error', { error: e.error ?? e.message });
65
+ });
66
+ addEventListener('unhandledrejection', (e) => {
67
+ logger.error('unhandled rejection', { error: e.reason });
68
+ });
69
+ }
@@ -0,0 +1,60 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+ export interface LogEvent {
3
+ _time: string;
4
+ app: string;
5
+ level: LogLevel;
6
+ severity: string;
7
+ source: 'server' | 'browser';
8
+ message: string;
9
+ [key: string]: unknown;
10
+ }
11
+ export interface Logger {
12
+ debug(message: string, meta?: Record<string, unknown>): void;
13
+ info(message: string, meta?: Record<string, unknown>): void;
14
+ warn(message: string, meta?: Record<string, unknown>): void;
15
+ error(message: string, meta?: Record<string, unknown>): void;
16
+ /** New logger sharing transport, with extra top-level fields on every event. */
17
+ with(context: Record<string, unknown>): Logger;
18
+ flush(): Promise<void>;
19
+ }
20
+ export interface SerializedError {
21
+ name: string;
22
+ message: string;
23
+ stack?: string;
24
+ cause?: {
25
+ name: string;
26
+ message: string;
27
+ };
28
+ }
29
+ export declare function serializeError(err: unknown): SerializedError;
30
+ export declare function truncate(s: string, max: number): string;
31
+ export interface ShipperOptions {
32
+ /** Full ingest URL, e.g. https://api.axiom.co/v1/datasets/apps/ingest */
33
+ url: string;
34
+ token: string;
35
+ batchSize?: number;
36
+ intervalMs?: number;
37
+ fetchFn?: typeof fetch;
38
+ /** Extra fetch options (browser sets keepalive). */
39
+ fetchInit?: RequestInit;
40
+ /** Called with the events that could not be delivered. */
41
+ onShipFailure?: (events: LogEvent[], error: unknown) => void;
42
+ }
43
+ export declare class LogShipper {
44
+ private readonly opts;
45
+ private queue;
46
+ private timer;
47
+ private inflight;
48
+ private readonly batchSize;
49
+ private readonly intervalMs;
50
+ constructor(opts: ShipperOptions);
51
+ enqueue(event: LogEvent): void;
52
+ flush(): Promise<void>;
53
+ private ship;
54
+ }
55
+ export interface EventFactoryOptions {
56
+ app: string;
57
+ source: 'server' | 'browser';
58
+ context?: Record<string, unknown>;
59
+ }
60
+ export declare function makeEvent(opts: EventFactoryOptions, level: LogLevel, message: string, meta?: Record<string, unknown>): LogEvent;
@@ -0,0 +1,99 @@
1
+ // Platform-neutral logging core: event shape, batching shipper, error
2
+ // serialization. Used by both the server logger (log.ts) and the browser
3
+ // logger (log-browser.ts). Ships to Axiom's native JSON ingest — chosen over
4
+ // OTLP for its synchronous commit and clean top-level field names (design:
5
+ // ~/migration/research/base-services-design.md §4).
6
+ export function serializeError(err) {
7
+ if (err instanceof Error) {
8
+ const out = { name: err.name, message: err.message, stack: err.stack };
9
+ if (err.cause instanceof Error) {
10
+ out.cause = { name: err.cause.name, message: err.cause.message };
11
+ }
12
+ else if (err.cause !== undefined) {
13
+ out.cause = { name: 'cause', message: truncate(String(err.cause), 500) };
14
+ }
15
+ return out;
16
+ }
17
+ return { name: 'NonError', message: truncate(String(err), 2000) };
18
+ }
19
+ export function truncate(s, max) {
20
+ return s.length <= max ? s : s.slice(0, max) + `…[+${s.length - max}]`;
21
+ }
22
+ // Batches events and ships them with plain fetch. Never throws from
23
+ // enqueue(); delivery failures go to onShipFailure (the server logger falls
24
+ // back to stdout, which Cloud Logging captures).
25
+ export class LogShipper {
26
+ opts;
27
+ queue = [];
28
+ timer;
29
+ inflight = Promise.resolve();
30
+ batchSize;
31
+ intervalMs;
32
+ constructor(opts) {
33
+ this.opts = opts;
34
+ this.batchSize = opts.batchSize ?? 50;
35
+ this.intervalMs = opts.intervalMs ?? 2000;
36
+ }
37
+ enqueue(event) {
38
+ this.queue.push(event);
39
+ if (this.queue.length >= this.batchSize) {
40
+ void this.flush();
41
+ }
42
+ else if (!this.timer) {
43
+ this.timer = setTimeout(() => void this.flush(), this.intervalMs);
44
+ this.timer.unref?.();
45
+ }
46
+ }
47
+ flush() {
48
+ if (this.timer) {
49
+ clearTimeout(this.timer);
50
+ this.timer = undefined;
51
+ }
52
+ if (this.queue.length === 0)
53
+ return this.inflight;
54
+ const batch = this.queue;
55
+ this.queue = [];
56
+ // Chain so batches can't reorder or race; errors never propagate.
57
+ this.inflight = this.inflight.then(() => this.ship(batch));
58
+ return this.inflight;
59
+ }
60
+ async ship(batch) {
61
+ const fetchFn = this.opts.fetchFn ?? fetch;
62
+ try {
63
+ const res = await fetchFn(this.opts.url, {
64
+ ...this.opts.fetchInit,
65
+ method: 'POST',
66
+ headers: {
67
+ authorization: `Bearer ${this.opts.token}`,
68
+ 'content-type': 'application/json',
69
+ },
70
+ body: JSON.stringify(batch),
71
+ });
72
+ if (!res.ok) {
73
+ this.opts.onShipFailure?.(batch, new Error(`ingest returned ${res.status}`));
74
+ }
75
+ }
76
+ catch (err) {
77
+ this.opts.onShipFailure?.(batch, err);
78
+ }
79
+ }
80
+ }
81
+ export function makeEvent(opts, level, message, meta) {
82
+ let m = meta;
83
+ if (m && m.error !== undefined && !isPlainSerialized(m.error)) {
84
+ m = { ...m, error: serializeError(m.error) };
85
+ }
86
+ return {
87
+ _time: new Date().toISOString(),
88
+ app: opts.app,
89
+ level,
90
+ severity: level.toUpperCase(),
91
+ source: opts.source,
92
+ message: truncate(message, 10_000),
93
+ ...opts.context,
94
+ ...(m ? { meta: m } : {}),
95
+ };
96
+ }
97
+ function isPlainSerialized(v) {
98
+ return typeof v === 'object' && v !== null && !(v instanceof Error) && 'message' in v && 'name' in v;
99
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { Logger } from './log-core.js';
2
+ export { LogShipper, serializeError, truncate } from './log-core.js';
3
+ export type { Logger, LogEvent, LogLevel, ShipperOptions } from './log-core.js';
4
+ export interface LoggerOptions {
5
+ /** App name, e.g. 'auth'. Becomes the `app` field queried in Axiom. */
6
+ app: string;
7
+ /** Axiom dataset. Default: env AXIOM_DATASET, else 'apps'. */
8
+ dataset?: string;
9
+ /** Ingest token. Default: env AXIOM_INGEST_TOKEN. Absent → stdout only. */
10
+ token?: string;
11
+ /** Default: env AXIOM_URL, else https://api.axiom.co */
12
+ baseUrl?: string;
13
+ /** Mirror every event to stdout (default true). */
14
+ stdout?: boolean;
15
+ context?: Record<string, unknown>;
16
+ fetchFn?: typeof fetch;
17
+ }
18
+ /** Flush every logger created by createLogger (used at graceful shutdown). */
19
+ export declare function flushAllLoggers(): Promise<void>;
20
+ export declare function createLogger(opts: LoggerOptions): Logger;
package/dist/log.js ADDED
@@ -0,0 +1,61 @@
1
+ // Server-side logger. Every event goes to stdout as one JSON line (free
2
+ // Cloud Logging backstop) and — when an ingest token is configured — to
3
+ // Axiom in batches. Without a token (local dev, tests) it is stdout-only.
4
+ //
5
+ // Conventions (new PW OPERATIONS.md): ERROR for anything that could reflect
6
+ // a real problem; log entry points, outbound calls, and DB writes with ids;
7
+ // rich meta is encouraged, but keep meta keys disciplined — every distinct
8
+ // flattened key becomes an Axiom field (dataset cap 1024).
9
+ import { LogShipper, makeEvent, } from './log-core.js';
10
+ export { LogShipper, serializeError, truncate } from './log-core.js';
11
+ const allLoggers = new Set();
12
+ /** Flush every logger created by createLogger (used at graceful shutdown). */
13
+ export async function flushAllLoggers() {
14
+ await Promise.all([...allLoggers].map((l) => l.flush()));
15
+ }
16
+ export function createLogger(opts) {
17
+ const token = opts.token ?? process.env.AXIOM_INGEST_TOKEN;
18
+ const dataset = opts.dataset ?? process.env.AXIOM_DATASET ?? 'apps';
19
+ const baseUrl = opts.baseUrl ?? process.env.AXIOM_URL ?? 'https://api.axiom.co';
20
+ const stdout = opts.stdout ?? true;
21
+ const shipper = token
22
+ ? new LogShipper({
23
+ url: `${baseUrl}/v1/datasets/${dataset}/ingest`,
24
+ token,
25
+ fetchFn: opts.fetchFn,
26
+ onShipFailure: (events, error) => {
27
+ // The stdout mirror already carried these events; record the
28
+ // delivery failure itself so Axiom gaps are explainable.
29
+ process.stdout.write(JSON.stringify({
30
+ severity: 'WARNING',
31
+ message: `axiom ship failed: ${String(error)} (${events.length} events; stdout mirror has them)`,
32
+ app: opts.app,
33
+ kind: 'log-ship-failure',
34
+ }) + '\n');
35
+ },
36
+ })
37
+ : undefined;
38
+ function build(context) {
39
+ const factory = { app: opts.app, source: 'server', context };
40
+ const emit = (level, message, meta) => {
41
+ const event = makeEvent(factory, level, message, meta);
42
+ if (stdout)
43
+ process.stdout.write(JSON.stringify(event) + '\n');
44
+ shipper?.enqueue(event);
45
+ };
46
+ const logger = {
47
+ debug: (m, meta) => emit('debug', m, meta),
48
+ info: (m, meta) => emit('info', m, meta),
49
+ warn: (m, meta) => emit('warn', m, meta),
50
+ error: (m, meta) => emit('error', m, meta),
51
+ with: (extra) => build({ ...context, ...extra }),
52
+ flush: () => shipper?.flush() ?? Promise.resolve(),
53
+ };
54
+ allLoggers.add(logger);
55
+ return logger;
56
+ }
57
+ const root = build(opts.context);
58
+ // Best-effort flush when the event loop drains (covers scripts/jobs).
59
+ process.once('beforeExit', () => void root.flush());
60
+ return root;
61
+ }
package/dist/rum.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface RumOptions {
2
+ app?: string;
3
+ dataset?: string;
4
+ token?: string;
5
+ baseUrl?: string;
6
+ }
7
+ export declare function initRum(opts?: RumOptions): void;
package/dist/rum.js ADDED
@@ -0,0 +1,56 @@
1
+ // Real-user monitoring: Core Web Vitals shipped to the Axiom `rum` dataset
2
+ // (targets: ~/migration/research/performance-targets.md). Configured by the
3
+ // same pw-log-* meta tags as log-browser; token-less pages (local dev)
4
+ // no-op. Metrics finalize on tab-hide, so the shipper flushes there with
5
+ // keepalive fetch.
6
+ //
7
+ // Browser-only module — do not import server-side.
8
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
9
+ import { LogShipper } from './log-core.js';
10
+ function metaContent(name) {
11
+ return document.querySelector(`meta[name="${name}"]`)?.content || undefined;
12
+ }
13
+ export function initRum(opts = {}) {
14
+ const app = opts.app ?? metaContent('pw-log-app') ?? 'unknown';
15
+ const token = opts.token ?? metaContent('pw-log-token');
16
+ if (!token)
17
+ return;
18
+ const dataset = opts.dataset ?? 'rum';
19
+ const baseUrl = (opts.baseUrl ?? 'https://api.axiom.co').replace(/\/$/, '');
20
+ const shipper = new LogShipper({
21
+ url: `${baseUrl}/v1/datasets/${dataset}/ingest`,
22
+ token,
23
+ fetchInit: { keepalive: true },
24
+ intervalMs: 5000,
25
+ });
26
+ const report = (m) => {
27
+ shipper.enqueue({
28
+ _time: new Date().toISOString(),
29
+ app,
30
+ // LogEvent-shaped enough for the shipper; rum rows are their own schema.
31
+ level: 'info',
32
+ severity: 'INFO',
33
+ source: 'browser',
34
+ message: m.name,
35
+ meta: {},
36
+ metric: m.name,
37
+ value: m.value,
38
+ rating: m.rating,
39
+ delta: m.delta,
40
+ metric_id: m.id,
41
+ navigation_type: m.navigationType,
42
+ path: location.pathname,
43
+ });
44
+ };
45
+ onLCP(report);
46
+ onINP(report);
47
+ onCLS(report);
48
+ onTTFB(report);
49
+ onFCP(report);
50
+ const flush = () => void shipper.flush();
51
+ window.addEventListener('pagehide', flush);
52
+ document.addEventListener('visibilitychange', () => {
53
+ if (document.visibilityState === 'hidden')
54
+ flush();
55
+ });
56
+ }
package/dist/s2s.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { OAuth2Client } from 'google-auth-library';
2
+ import type { Logger } from './log-core.js';
3
+ /** The fleet coding agent's service-account email, read from AGENT_SA — set at
4
+ * deploy time by each service (SECURITY.md: agents have full access, so
5
+ * grantAllows always admits this identity). Env-driven rather than a baked-in
6
+ * constant so the published package carries no deployment identifiers. Unset
7
+ * disables the always-allow rule (fail closed). */
8
+ export declare function codingAgentSa(): string | undefined;
9
+ /** caller service-account email → allowed "METHOD /path" patterns.
10
+ * Patterns: exact ("GET /api/whoami"), trailing wildcard ("POST /api/admin/*"),
11
+ * or "*" for everything. */
12
+ export type Grants = Record<string, string[]>;
13
+ export interface ServiceCaller {
14
+ type: 's2s';
15
+ email: string;
16
+ }
17
+ declare module 'hono' {
18
+ interface ContextVariableMap {
19
+ caller: ServiceCaller;
20
+ }
21
+ }
22
+ export declare function grantAllows(grants: Grants, email: string, method: string, path: string): boolean;
23
+ export declare class RateLimiter {
24
+ private readonly perMinute;
25
+ private windows;
26
+ constructor(perMinute: number);
27
+ allow(key: string): boolean;
28
+ }
29
+ export interface S2sOptions {
30
+ /** This service's canonical URL, e.g. https://auth.apps.snewman.net — the
31
+ * audience callers must mint tokens for. */
32
+ audience: string;
33
+ grants: Grants;
34
+ logger?: Logger;
35
+ /** Per-caller request ceiling (default 600/min). */
36
+ rateLimitPerMin?: number;
37
+ /** Injectable for tests. */
38
+ verifyClient?: OAuth2Client;
39
+ }
40
+ export interface ResolvedCaller {
41
+ caller?: ServiceCaller;
42
+ /** Response status to return when caller is undefined. */
43
+ failure?: {
44
+ status: 401 | 403 | 429;
45
+ error: string;
46
+ };
47
+ }
48
+ /** Resolve and authorize an s2s caller from a request's Authorization header.
49
+ * Shared by s2sAuth() and auth.ts's requireIdentity(). */
50
+ export declare function resolveS2sCaller(opts: S2sOptions & {
51
+ limiter: RateLimiter;
52
+ }, headers: {
53
+ authorization?: string;
54
+ testCaller?: string;
55
+ }, method: string, path: string): Promise<ResolvedCaller>;
56
+ /** Middleware for endpoints that accept only service callers (no browser
57
+ * sessions) — e.g. scheduler pokes, Pub/Sub push, admin APIs. */
58
+ export declare function s2sAuth(opts: S2sOptions): import("hono").MiddlewareHandler<any, string, {}, Response | (Response & import("hono").TypedResponse<{
59
+ error: string;
60
+ }, 401 | 403 | 429, "json">)>;
61
+ /** fetch() with an OIDC identity token for the target's canonical audience.
62
+ * In test mode, sends the synthetic X-Test-S2S-Caller header instead
63
+ * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). */
64
+ export declare function serviceFetch(url: string, opts: {
65
+ audience: string;
66
+ } & RequestInit): Promise<Response>;
package/dist/s2s.js ADDED
@@ -0,0 +1,150 @@
1
+ // Service-to-service authentication (design:
2
+ // ~/migration/research/base-services-design.md §5).
3
+ //
4
+ // Mechanism: Google-signed OIDC identity tokens. Callers (Cloud Run services
5
+ // via the metadata server, the coding agent via its service-account
6
+ // credential, Cloud Scheduler / Pub-Sub natively) mint a short-lived ID token
7
+ // with audience = the callee's canonical URL; the callee verifies it against
8
+ // Google's certificates and authorizes the caller's service-account identity
9
+ // against a checked-in per-method grants table. No long-lived credential
10
+ // exists anywhere on this path.
11
+ //
12
+ // The grants table lives in each app's repo (src/grants.ts) and is identical
13
+ // in test and production by construction. The coding agent's SA is always
14
+ // allowed (SECURITY.md: agents have full access).
15
+ import { createMiddleware } from 'hono/factory';
16
+ import { OAuth2Client } from 'google-auth-library';
17
+ import { authMode } from './config.js';
18
+ /** The fleet coding agent's service-account email, read from AGENT_SA — set at
19
+ * deploy time by each service (SECURITY.md: agents have full access, so
20
+ * grantAllows always admits this identity). Env-driven rather than a baked-in
21
+ * constant so the published package carries no deployment identifiers. Unset
22
+ * disables the always-allow rule (fail closed). */
23
+ export function codingAgentSa() {
24
+ return process.env.AGENT_SA || undefined;
25
+ }
26
+ export function grantAllows(grants, email, method, path) {
27
+ const agent = codingAgentSa();
28
+ if (agent && email === agent)
29
+ return true;
30
+ const patterns = grants[email];
31
+ if (!patterns)
32
+ return false;
33
+ const want = `${method.toUpperCase()} ${path}`;
34
+ return patterns.some((p) => {
35
+ if (p === '*')
36
+ return true;
37
+ if (p.endsWith('/*')) {
38
+ const prefix = p.slice(0, -1); // keep the trailing slash
39
+ return want.startsWith(prefix) || want === p.slice(0, -2);
40
+ }
41
+ return want === p;
42
+ });
43
+ }
44
+ // Fixed-window per-identity rate limiter — a sanity check against runaway
45
+ // loops and mischief, not a precision tool (per-instance state only).
46
+ export class RateLimiter {
47
+ perMinute;
48
+ windows = new Map();
49
+ constructor(perMinute) {
50
+ this.perMinute = perMinute;
51
+ }
52
+ allow(key) {
53
+ const now = Date.now();
54
+ const w = this.windows.get(key);
55
+ if (!w || now - w.start >= 60_000) {
56
+ this.windows.set(key, { start: now, count: 1 });
57
+ return true;
58
+ }
59
+ w.count++;
60
+ return w.count <= this.perMinute;
61
+ }
62
+ }
63
+ /** Resolve and authorize an s2s caller from a request's Authorization header.
64
+ * Shared by s2sAuth() and auth.ts's requireIdentity(). */
65
+ export async function resolveS2sCaller(opts, headers, method, path) {
66
+ let email;
67
+ if (authMode() === 'test') {
68
+ email = headers.testCaller;
69
+ if (!email)
70
+ return { failure: { status: 401, error: 'no s2s credential (test mode: set X-Test-S2S-Caller)' } };
71
+ }
72
+ else {
73
+ const m = headers.authorization?.match(/^Bearer (.+)$/i);
74
+ if (!m)
75
+ return { failure: { status: 401, error: 'missing bearer token' } };
76
+ try {
77
+ const client = opts.verifyClient ?? defaultVerifyClient();
78
+ const ticket = await client.verifyIdToken({ idToken: m[1], audience: opts.audience });
79
+ const payload = ticket.getPayload();
80
+ if (!payload?.email || payload.email_verified === false) {
81
+ return { failure: { status: 401, error: 'token has no verified identity' } };
82
+ }
83
+ email = payload.email;
84
+ }
85
+ catch (err) {
86
+ opts.logger?.warn('s2s token verification failed', { error: err, path });
87
+ return { failure: { status: 401, error: 'invalid token' } };
88
+ }
89
+ }
90
+ if (!opts.limiter.allow(email)) {
91
+ opts.logger?.warn('s2s rate limit exceeded', { caller: email, path });
92
+ return { failure: { status: 429, error: 'rate limit exceeded' } };
93
+ }
94
+ if (!grantAllows(opts.grants, email, method, path)) {
95
+ // ERROR by policy: a denied identity is either an attack or a missing
96
+ // grant during development — both deserve attention.
97
+ opts.logger?.error('s2s call denied by grants', { caller: email, method, path });
98
+ return { failure: { status: 403, error: `${email} is not granted ${method} ${path}` } };
99
+ }
100
+ return { caller: { type: 's2s', email } };
101
+ }
102
+ let sharedVerifyClient;
103
+ function defaultVerifyClient() {
104
+ sharedVerifyClient ??= new OAuth2Client();
105
+ return sharedVerifyClient;
106
+ }
107
+ /** Middleware for endpoints that accept only service callers (no browser
108
+ * sessions) — e.g. scheduler pokes, Pub/Sub push, admin APIs. */
109
+ export function s2sAuth(opts) {
110
+ const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
111
+ return createMiddleware(async (c, next) => {
112
+ const resolved = await resolveS2sCaller({ ...opts, limiter }, {
113
+ authorization: c.req.header('authorization'),
114
+ testCaller: c.req.header('x-test-s2s-caller'),
115
+ }, c.req.method, c.req.path);
116
+ if (!resolved.caller) {
117
+ const f = resolved.failure;
118
+ return c.json({ error: f.error }, f.status);
119
+ }
120
+ c.set('caller', resolved.caller);
121
+ await next();
122
+ });
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Caller side
126
+ const idTokenClients = new Map();
127
+ /** fetch() with an OIDC identity token for the target's canonical audience.
128
+ * In test mode, sends the synthetic X-Test-S2S-Caller header instead
129
+ * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). */
130
+ export async function serviceFetch(url, opts) {
131
+ const { audience, ...init } = opts;
132
+ const headers = new Headers(init.headers);
133
+ if (authMode() === 'test') {
134
+ headers.set('x-test-s2s-caller', process.env.TEST_S2S_IDENTITY ?? 'test-service@test');
135
+ }
136
+ else {
137
+ let clientPromise = idTokenClients.get(audience);
138
+ if (!clientPromise) {
139
+ clientPromise = (async () => {
140
+ const { GoogleAuth } = await import('google-auth-library');
141
+ return new GoogleAuth().getIdTokenClient(audience);
142
+ })();
143
+ idTokenClients.set(audience, clientPromise);
144
+ }
145
+ const client = await clientPromise;
146
+ const token = await client.idTokenProvider.fetchIdToken(audience);
147
+ headers.set('authorization', `Bearer ${token}`);
148
+ }
149
+ return fetch(url, { ...init, headers });
150
+ }
@@ -0,0 +1,25 @@
1
+ export interface TestOwnersOptions {
2
+ /** Repo root; srcDir and testDir are resolved against it. */
3
+ root: string;
4
+ srcDir?: string;
5
+ testDir?: string;
6
+ }
7
+ export interface TestOwnersReport {
8
+ /** Modules with no test file at the mirrored path. */
9
+ missing: {
10
+ module: string;
11
+ expectedTest: string;
12
+ }[];
13
+ /** Test files with no module at the mirrored path (usually a stale name). */
14
+ orphans: {
15
+ test: string;
16
+ expectedModule: string;
17
+ }[];
18
+ /** Modules checked. Zero means the src directory was empty or misspelled. */
19
+ checked: number;
20
+ }
21
+ export declare function checkTestOwners(opts: TestOwnersOptions): Promise<TestOwnersReport>;
22
+ /** Format a report for the terminal. Empty string when everything passes. */
23
+ export declare function formatTestOwnersReport(report: TestOwnersReport): string;
24
+ /** CLI entry point. Flags: --root, --src, --test. Returns a process exit code. */
25
+ export declare function runTestOwnersCli(argv: string[], write?: (line: string) => void): Promise<number>;
@@ -0,0 +1,106 @@
1
+ // Structural test-coverage gate (PW docs/TESTING.md, "Before calling a build
2
+ // done"): every module under src/ must have a test file at the mirrored path
3
+ // under test/, and every test file must mirror a module.
4
+ //
5
+ // Deliberately no exemption list. A module that genuinely needs no test still
6
+ // gets its test file, containing nothing but a comment saying why. That keeps
7
+ // the check trivially mechanical — does the file exist? — puts the excuse next
8
+ // to the code it excuses, and avoids a separate registry that drifts.
9
+ //
10
+ // Scope is `.ts` only; `.tsx` is out of scope by decision (docs/TESTING.md,
11
+ // "The src -> test mapping"). `.d.ts` files are declarations, not modules.
12
+ import { readdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ const MODULE_EXT = '.ts';
15
+ const TEST_EXT = '.test.ts';
16
+ /** Files under `dir` (recursively) ending in `ext`, as paths relative to
17
+ * `dir` with forward slashes. A missing directory yields no files. */
18
+ async function listFiles(dir, ext) {
19
+ let entries;
20
+ try {
21
+ entries = await readdir(dir, { recursive: true });
22
+ }
23
+ catch (err) {
24
+ if (err.code === 'ENOENT')
25
+ return [];
26
+ throw err;
27
+ }
28
+ return entries
29
+ .map((e) => e.split('\\').join('/'))
30
+ .filter((e) => e.endsWith(ext) && !e.endsWith('.d.ts'))
31
+ .sort();
32
+ }
33
+ export async function checkTestOwners(opts) {
34
+ const srcDir = opts.srcDir ?? 'src';
35
+ const testDir = opts.testDir ?? 'test';
36
+ const modules = (await listFiles(join(opts.root, srcDir), MODULE_EXT)).filter((m) => !m.endsWith(TEST_EXT));
37
+ const tests = new Set(await listFiles(join(opts.root, testDir), TEST_EXT));
38
+ const missing = [];
39
+ for (const mod of modules) {
40
+ const expected = `${mod.slice(0, -MODULE_EXT.length)}${TEST_EXT}`;
41
+ if (!tests.has(expected)) {
42
+ missing.push({ module: `${srcDir}/${mod}`, expectedTest: `${testDir}/${expected}` });
43
+ }
44
+ }
45
+ const moduleSet = new Set(modules);
46
+ const orphans = [];
47
+ for (const test of tests) {
48
+ const expected = `${test.slice(0, -TEST_EXT.length)}${MODULE_EXT}`;
49
+ if (!moduleSet.has(expected)) {
50
+ orphans.push({ test: `${testDir}/${test}`, expectedModule: `${srcDir}/${expected}` });
51
+ }
52
+ }
53
+ return { missing, orphans, checked: modules.length };
54
+ }
55
+ function pad(items) {
56
+ const width = Math.max(...items.map((i) => i.a.length));
57
+ return items.map((i) => ` ${i.a.padEnd(width)} ${i.b}`);
58
+ }
59
+ function count(n, noun) {
60
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
61
+ }
62
+ /** Format a report for the terminal. Empty string when everything passes. */
63
+ export function formatTestOwnersReport(report) {
64
+ const out = [];
65
+ if (report.missing.length > 0) {
66
+ out.push(`${count(report.missing.length, 'module')} with no test file:`, ...pad(report.missing.map((m) => ({ a: m.module, b: `-> ${m.expectedTest}` }))), '', 'Every module needs a test file at the mirrored path. A module that', 'genuinely needs no test still gets the file, containing only a comment', 'saying why (PW docs/TESTING.md, "Before calling a build done").');
67
+ }
68
+ if (report.orphans.length > 0) {
69
+ if (out.length > 0)
70
+ out.push('');
71
+ out.push(`${count(report.orphans.length, 'test file')} mirroring no module:`, ...pad(report.orphans.map((o) => ({ a: o.test, b: `expected ${o.expectedModule}` }))), '', 'Rename the test to mirror the module it covers, or delete it if the', 'module is gone.');
72
+ }
73
+ return out.join('\n');
74
+ }
75
+ /** CLI entry point. Flags: --root, --src, --test. Returns a process exit code. */
76
+ export async function runTestOwnersCli(argv, write = (line) => console.log(line)) {
77
+ const opts = { root: process.cwd() };
78
+ for (let i = 0; i < argv.length; i += 2) {
79
+ const value = argv[i + 1];
80
+ if (value === undefined) {
81
+ write(`test-owners: missing value for ${argv[i]}`);
82
+ return 2;
83
+ }
84
+ if (argv[i] === '--root')
85
+ opts.root = value;
86
+ else if (argv[i] === '--src')
87
+ opts.srcDir = value;
88
+ else if (argv[i] === '--test')
89
+ opts.testDir = value;
90
+ else {
91
+ write(`test-owners: unknown option ${argv[i]} (expected --root, --src, --test)`);
92
+ return 2;
93
+ }
94
+ }
95
+ const report = await checkTestOwners(opts);
96
+ if (report.checked === 0) {
97
+ write(`test-owners: no modules found under ${opts.srcDir ?? 'src'}/ — wrong --src?`);
98
+ return 2;
99
+ }
100
+ if (report.missing.length === 0 && report.orphans.length === 0) {
101
+ write(`test-owners: ${count(report.checked, 'module')}, all owned`);
102
+ return 0;
103
+ }
104
+ write(`test-owners: FAILED\n\n${formatTestOwnersReport(report)}`);
105
+ return 1;
106
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@steve31415/baselib",
3
+ "version": "1.0.0",
4
+ "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, http",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": "github:plasticine-apps/baselib",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./config": {
17
+ "types": "./dist/config.d.ts",
18
+ "default": "./dist/config.js"
19
+ },
20
+ "./log": {
21
+ "types": "./dist/log.d.ts",
22
+ "default": "./dist/log.js"
23
+ },
24
+ "./log-browser": {
25
+ "types": "./dist/log-browser.d.ts",
26
+ "default": "./dist/log-browser.js"
27
+ },
28
+ "./s2s": {
29
+ "types": "./dist/s2s.d.ts",
30
+ "default": "./dist/s2s.js"
31
+ },
32
+ "./auth": {
33
+ "types": "./dist/auth.d.ts",
34
+ "default": "./dist/auth.js"
35
+ },
36
+ "./db": {
37
+ "types": "./dist/db.d.ts",
38
+ "default": "./dist/db.js"
39
+ },
40
+ "./http": {
41
+ "types": "./dist/http.d.ts",
42
+ "default": "./dist/http.js"
43
+ },
44
+ "./rum": {
45
+ "types": "./dist/rum.d.ts",
46
+ "default": "./dist/rum.js"
47
+ }
48
+ },
49
+ "bin": {
50
+ "check-test-owners": "./dist/bin/check-test-owners.js"
51
+ },
52
+ "engines": {
53
+ "node": "22.x"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc -p tsconfig.build.json",
57
+ "prepack": "npm run build",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run",
60
+ "check:test-owners": "tsx src/bin/check-test-owners.ts",
61
+ "verify": "npm run typecheck && npm run check:test-owners && npm run test && npm run build"
62
+ },
63
+ "dependencies": {
64
+ "@google-cloud/cloud-sql-connector": "^1.8.2",
65
+ "@hono/node-server": "^1.14.0",
66
+ "google-auth-library": "^10.5.0",
67
+ "hono": "^4.7.0",
68
+ "pg": "^8.14.0",
69
+ "web-vitals": "^6.1.1"
70
+ },
71
+ "devDependencies": {
72
+ "@types/node": "^22.0.0",
73
+ "@types/pg": "^8.11.0",
74
+ "happy-dom": "^18.0.0",
75
+ "tsx": "^4.19.0",
76
+ "typescript": "^5.7.0",
77
+ "vitest": "^3.0.0"
78
+ }
79
+ }