@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 +14 -0
- package/dist/auth.d.ts +37 -0
- package/dist/auth.js +102 -0
- package/dist/bin/check-test-owners.d.ts +2 -0
- package/dist/bin/check-test-owners.js +5 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +27 -0
- package/dist/db.d.ts +11 -0
- package/dist/db.js +76 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.js +69 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/log-browser.d.ts +15 -0
- package/dist/log-browser.js +69 -0
- package/dist/log-core.d.ts +60 -0
- package/dist/log-core.js +99 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.js +61 -0
- package/dist/rum.d.ts +7 -0
- package/dist/rum.js +56 -0
- package/dist/s2s.d.ts +66 -0
- package/dist/s2s.js +150 -0
- package/dist/test-owners.d.ts +25 -0
- package/dist/test-owners.js +106 -0
- package/package.json +79 -0
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
|
+
}
|
package/dist/config.d.ts
ADDED
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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;
|
package/dist/log-core.js
ADDED
|
@@ -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
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
|
+
}
|