@steve31415/baselib 3.25.0 → 3.27.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 +1 -1
- package/dist/http.d.ts +10 -0
- package/dist/http.js +17 -0
- package/dist/oauth-resource.d.ts +85 -0
- package/dist/oauth-resource.js +257 -0
- package/dist/s2s.d.ts +1 -0
- package/dist/s2s.js +27 -1
- package/dist/sync-browser/client.d.ts +8 -0
- package/dist/sync-browser/client.js +77 -5
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
|
|
|
5
5
|
`docs/IMPL.md`. Full design rationale with alternatives:
|
|
6
6
|
`~/migration/research/base-services-design.md`.
|
|
7
7
|
|
|
8
|
-
Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
|
|
8
|
+
Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `s2s`, `db`, `http`, `sync`,
|
|
9
9
|
`app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
|
|
10
10
|
`slow-fetch`, `sync-browser`, and `app-update-browser`.
|
|
11
11
|
|
package/dist/http.d.ts
CHANGED
|
@@ -5,6 +5,16 @@ import type { Logger } from './log-core.js';
|
|
|
5
5
|
* identity); /health excluded. Entry-point detail beyond this (key
|
|
6
6
|
* parameters, outcomes) is the handler's job per OPERATIONS.md. */
|
|
7
7
|
export declare function requestLogging(logger: Logger): import("hono").MiddlewareHandler<any, string, {}, Response>;
|
|
8
|
+
/** The address of the client that connected to Google's edge, read from
|
|
9
|
+
* X-Forwarded-For — for per-source rate limits, never for authorization.
|
|
10
|
+
* Google appends to whatever the client sent, so only the tail can be
|
|
11
|
+
* trusted: through the fleet load balancer the header ends
|
|
12
|
+
* `<client>,<load balancer>` (Google's Application Load Balancer docs: it
|
|
13
|
+
* "does not verify any IP addresses that precede" those two), and a request
|
|
14
|
+
* made straight to the service's run.app URL ends `<client>`. `canonicalHost`
|
|
15
|
+
* is the app's public host: a request for any other host did not come
|
|
16
|
+
* through the load balancer. */
|
|
17
|
+
export declare function forwardedClientIp(c: Context, canonicalHost: string): string;
|
|
8
18
|
export declare function addHealth(app: Hono): void;
|
|
9
19
|
/** app.onError handler: log with full context, return a plain 500. */
|
|
10
20
|
export declare function errorHandler(logger: Logger): (err: Error, c: Context) => (Response & import("hono").TypedResponse<{
|
package/dist/http.js
CHANGED
|
@@ -25,6 +25,23 @@ export function requestLogging(logger) {
|
|
|
25
25
|
});
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
|
+
/** The address of the client that connected to Google's edge, read from
|
|
29
|
+
* X-Forwarded-For — for per-source rate limits, never for authorization.
|
|
30
|
+
* Google appends to whatever the client sent, so only the tail can be
|
|
31
|
+
* trusted: through the fleet load balancer the header ends
|
|
32
|
+
* `<client>,<load balancer>` (Google's Application Load Balancer docs: it
|
|
33
|
+
* "does not verify any IP addresses that precede" those two), and a request
|
|
34
|
+
* made straight to the service's run.app URL ends `<client>`. `canonicalHost`
|
|
35
|
+
* is the app's public host: a request for any other host did not come
|
|
36
|
+
* through the load balancer. */
|
|
37
|
+
export function forwardedClientIp(c, canonicalHost) {
|
|
38
|
+
const hops = (c.req.header('x-forwarded-for') ?? '')
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((hop) => hop.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
const viaLoadBalancer = (c.req.header('host') ?? '').toLowerCase() === canonicalHost.toLowerCase();
|
|
43
|
+
return hops.at(viaLoadBalancer ? -2 : -1) ?? 'unknown';
|
|
44
|
+
}
|
|
28
45
|
export function addHealth(app) {
|
|
29
46
|
app.get('/health', (c) => c.json({
|
|
30
47
|
ok: true,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { UserAccessPolicy, UserRole } from './auth.js';
|
|
3
|
+
import type { Logger } from './log-core.js';
|
|
4
|
+
import { serviceFetch } from './s2s.js';
|
|
5
|
+
/** The exact shape of an access token: the prefix plus 32 random bytes in
|
|
6
|
+
* base64url. Anything else is refused before any cache or network work. */
|
|
7
|
+
export declare const ACCESS_TOKEN_PATTERN: RegExp;
|
|
8
|
+
/** A user acting through a connector, limited to the scopes they approved. */
|
|
9
|
+
export interface ConnectorUser {
|
|
10
|
+
type: 'connector';
|
|
11
|
+
email: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
userId?: string;
|
|
14
|
+
role: UserRole;
|
|
15
|
+
scopes: ReadonlySet<string>;
|
|
16
|
+
/** The OAuth client the user approved (e.g. Claude's client ID URL). */
|
|
17
|
+
clientId: string;
|
|
18
|
+
/** The consent this token descends from; revoking it kills the token. */
|
|
19
|
+
grantId: string;
|
|
20
|
+
}
|
|
21
|
+
declare module 'hono' {
|
|
22
|
+
interface ContextVariableMap {
|
|
23
|
+
connector: ConnectorUser;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export interface ConnectorResourceOptions {
|
|
27
|
+
/** This endpoint's exact public URL, e.g. https://notes.apps.snewman.net/mcp
|
|
28
|
+
* — the OAuth resource tokens must have been issued for. */
|
|
29
|
+
resource: string;
|
|
30
|
+
/** Every scope this resource defines (advertised in metadata and
|
|
31
|
+
* challenges). */
|
|
32
|
+
scopes: readonly string[];
|
|
33
|
+
/** Base URL of the auth service (the authorization server). */
|
|
34
|
+
authUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface RequireConnectorUserOptions extends ConnectorResourceOptions {
|
|
37
|
+
/** Which users this app admits — the same policy as requireIdentity. */
|
|
38
|
+
userAccess?: UserAccessPolicy;
|
|
39
|
+
logger?: Logger;
|
|
40
|
+
/** Per-user request ceiling (default 600/min). */
|
|
41
|
+
rateLimitPerMin?: number;
|
|
42
|
+
/** Injectable for tests: performs the introspection call for one token. */
|
|
43
|
+
introspect?: (token: string) => Promise<Response>;
|
|
44
|
+
cache?: {
|
|
45
|
+
hitMs?: number;
|
|
46
|
+
missMs?: number;
|
|
47
|
+
maxEntries?: number;
|
|
48
|
+
};
|
|
49
|
+
/** Uncached tokens one source address may have introspected per minute
|
|
50
|
+
* (default 60). A connector's own traffic needs one per token per cache
|
|
51
|
+
* window, so this only ever binds on a stream of unknown tokens. */
|
|
52
|
+
introspectionsPerSourcePerMin?: number;
|
|
53
|
+
now?: () => number;
|
|
54
|
+
}
|
|
55
|
+
/** The introspection call itself: s2s OIDC to the auth service, bounded in
|
|
56
|
+
* time including the ID-token mint. */
|
|
57
|
+
export declare function introspectAtAuth(authUrl: string, token: string, fetcher?: typeof serviceFetch): Promise<Response>;
|
|
58
|
+
/** Where the RFC 9728 document for `resource` is served: the well-known
|
|
59
|
+
* prefix inserted before the resource's path. Built from configuration,
|
|
60
|
+
* never from the request URL (which is http:// behind the load balancer). */
|
|
61
|
+
export declare function resourceMetadataUrl(resource: string): string;
|
|
62
|
+
/** The RFC 9728 protected-resource metadata document. */
|
|
63
|
+
export declare function protectedResourceMetadata(opts: ConnectorResourceOptions): {
|
|
64
|
+
resource: string;
|
|
65
|
+
authorization_servers: string[];
|
|
66
|
+
scopes_supported: string[];
|
|
67
|
+
bearer_methods_supported: string[];
|
|
68
|
+
};
|
|
69
|
+
/** 403 that asks the client to re-run consent for more scope. The challenge
|
|
70
|
+
* names every scope of the resource, because a client does not reliably
|
|
71
|
+
* carry earlier scopes into a step-up. */
|
|
72
|
+
export declare function insufficientScope(c: Context, opts: ConnectorResourceOptions): Response & import("hono").TypedResponse<{
|
|
73
|
+
error: string;
|
|
74
|
+
}, 403, "json">;
|
|
75
|
+
export declare function requireConnectorUser(opts: RequireConnectorUserOptions): import("hono").MiddlewareHandler<any, string, {}, (Response & import("hono").TypedResponse<{
|
|
76
|
+
error: string;
|
|
77
|
+
}, 401, "json">) | (Response & import("hono").TypedResponse<{
|
|
78
|
+
error: string;
|
|
79
|
+
code: string;
|
|
80
|
+
email: string;
|
|
81
|
+
}, 403, "json">) | (Response & import("hono").TypedResponse<{
|
|
82
|
+
error: string;
|
|
83
|
+
}, 429, "json">) | (Response & import("hono").TypedResponse<{
|
|
84
|
+
error: string;
|
|
85
|
+
}, 503, "json">)>;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Resource-server side of the fleet's OAuth access tokens — the credential a
|
|
2
|
+
// connector (claude.ai's MCP client) presents on a user's behalf.
|
|
3
|
+
//
|
|
4
|
+
// The auth service is the authorization server: it shows the consent page and
|
|
5
|
+
// issues opaque `pwat_…` access tokens bound to exactly one resource (an
|
|
6
|
+
// app's MCP endpoint URL) and a scope list. An app that serves such an
|
|
7
|
+
// endpoint mounts requireConnectorUser() on that route ONLY. It is
|
|
8
|
+
// deliberately separate from requireIdentity(): a connector token must never
|
|
9
|
+
// be admitted on an app's ordinary user routes, so it produces its own
|
|
10
|
+
// context variable (`connector`) and never sets `identity` or `user`.
|
|
11
|
+
//
|
|
12
|
+
// Resolution, in order:
|
|
13
|
+
// 1. Test bypass (AUTH_MODE=test): X-Test-Connector-User (+ -Role, -Scope).
|
|
14
|
+
// Credential verification is bypassed; the access policy and the app's
|
|
15
|
+
// scope checks still run.
|
|
16
|
+
// 2. Authorization: Bearer pwat_… → introspection at the auth service over
|
|
17
|
+
// s2s OIDC, cached in-instance (5 min hits, capped by the token's own
|
|
18
|
+
// expiry / 30 s misses, bounded LRU). Introspections are budgeted per
|
|
19
|
+
// source address, so a stream of junk tokens costs the auth service a
|
|
20
|
+
// bounded number of calls and costs other callers nothing.
|
|
21
|
+
//
|
|
22
|
+
// Failure mapping matters to the client. A 401 makes it discard its token and
|
|
23
|
+
// re-run sign-in, so 401 is reserved for "this token is not valid here".
|
|
24
|
+
// Anything that is OUR fault or the auth service's (unreachable, slow, any
|
|
25
|
+
// non-200, a malformed answer) is 503 and is never cached.
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { createMiddleware } from 'hono/factory';
|
|
28
|
+
import { ACCESS_DENIED_CODE, DEFAULT_AUTH_URL, userAllowed } from './auth.js';
|
|
29
|
+
import { authMode } from './config.js';
|
|
30
|
+
import { forwardedClientIp } from './http.js';
|
|
31
|
+
import { RateLimiter, serviceFetch } from './s2s.js';
|
|
32
|
+
/** The exact shape of an access token: the prefix plus 32 random bytes in
|
|
33
|
+
* base64url. Anything else is refused before any cache or network work. */
|
|
34
|
+
export const ACCESS_TOKEN_PATTERN = /^pwat_[A-Za-z0-9_-]{43}$/;
|
|
35
|
+
const INTROSPECT_TIMEOUT_MS = 5_000;
|
|
36
|
+
/** The introspection call itself: s2s OIDC to the auth service, bounded in
|
|
37
|
+
* time including the ID-token mint. */
|
|
38
|
+
export function introspectAtAuth(authUrl, token, fetcher = serviceFetch) {
|
|
39
|
+
let timer;
|
|
40
|
+
const timeout = new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => reject(new Error('introspection timed out')), INTROSPECT_TIMEOUT_MS);
|
|
42
|
+
});
|
|
43
|
+
const call = fetcher(`${authUrl}/api/oauth/introspect`, {
|
|
44
|
+
audience: authUrl,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ token }),
|
|
48
|
+
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
|
|
49
|
+
});
|
|
50
|
+
return Promise.race([call, timeout]).finally(() => clearTimeout(timer));
|
|
51
|
+
}
|
|
52
|
+
function authBase(opts) {
|
|
53
|
+
return (opts.authUrl ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
|
|
54
|
+
}
|
|
55
|
+
/** Where the RFC 9728 document for `resource` is served: the well-known
|
|
56
|
+
* prefix inserted before the resource's path. Built from configuration,
|
|
57
|
+
* never from the request URL (which is http:// behind the load balancer). */
|
|
58
|
+
export function resourceMetadataUrl(resource) {
|
|
59
|
+
const url = new URL(resource);
|
|
60
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
61
|
+
return `${url.origin}/.well-known/oauth-protected-resource${path}`;
|
|
62
|
+
}
|
|
63
|
+
/** The RFC 9728 protected-resource metadata document. */
|
|
64
|
+
export function protectedResourceMetadata(opts) {
|
|
65
|
+
return {
|
|
66
|
+
resource: opts.resource,
|
|
67
|
+
authorization_servers: [authBase(opts)],
|
|
68
|
+
scopes_supported: [...opts.scopes],
|
|
69
|
+
bearer_methods_supported: ['header'],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function challenge(opts, error) {
|
|
73
|
+
return [
|
|
74
|
+
...(error ? [`error="${error}"`] : []),
|
|
75
|
+
`resource_metadata="${resourceMetadataUrl(opts.resource)}"`,
|
|
76
|
+
`scope="${opts.scopes.join(' ')}"`,
|
|
77
|
+
].join(', ');
|
|
78
|
+
}
|
|
79
|
+
function unauthorized(c, opts, error) {
|
|
80
|
+
c.header('www-authenticate', `Bearer ${challenge(opts, error)}`);
|
|
81
|
+
c.header('cache-control', 'no-store');
|
|
82
|
+
return c.json({ error: error ?? 'unauthorized' }, 401);
|
|
83
|
+
}
|
|
84
|
+
/** 403 that asks the client to re-run consent for more scope. The challenge
|
|
85
|
+
* names every scope of the resource, because a client does not reliably
|
|
86
|
+
* carry earlier scopes into a step-up. */
|
|
87
|
+
export function insufficientScope(c, opts) {
|
|
88
|
+
c.header('www-authenticate', `Bearer ${challenge(opts, 'insufficient_scope')}`);
|
|
89
|
+
c.header('cache-control', 'no-store');
|
|
90
|
+
return c.json({ error: 'insufficient_scope' }, 403);
|
|
91
|
+
}
|
|
92
|
+
/** A Map in insertion order doubles as an LRU: a hit is re-inserted, and the
|
|
93
|
+
* oldest key is dropped when full. Unauthenticated callers choose the keys,
|
|
94
|
+
* so the bound is what keeps them from growing the process. */
|
|
95
|
+
class LruCache {
|
|
96
|
+
maxEntries;
|
|
97
|
+
entries = new Map();
|
|
98
|
+
constructor(maxEntries) {
|
|
99
|
+
this.maxEntries = maxEntries;
|
|
100
|
+
}
|
|
101
|
+
get(key, now) {
|
|
102
|
+
const entry = this.entries.get(key);
|
|
103
|
+
if (!entry)
|
|
104
|
+
return undefined;
|
|
105
|
+
this.entries.delete(key);
|
|
106
|
+
if (entry.expires <= now)
|
|
107
|
+
return undefined;
|
|
108
|
+
this.entries.set(key, entry);
|
|
109
|
+
return entry;
|
|
110
|
+
}
|
|
111
|
+
set(key, entry) {
|
|
112
|
+
this.entries.delete(key);
|
|
113
|
+
this.entries.set(key, entry);
|
|
114
|
+
if (this.entries.size > this.maxEntries) {
|
|
115
|
+
this.entries.delete(this.entries.keys().next().value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function parseRole(value) {
|
|
120
|
+
return value === 'owner' || value === 'invited' ? value : undefined;
|
|
121
|
+
}
|
|
122
|
+
function parseScopes(value) {
|
|
123
|
+
return new Set(typeof value === 'string' ? value.split(' ').filter(Boolean) : []);
|
|
124
|
+
}
|
|
125
|
+
function nonEmptyString(value) {
|
|
126
|
+
return typeof value === 'string' && value.length > 0;
|
|
127
|
+
}
|
|
128
|
+
export function requireConnectorUser(opts) {
|
|
129
|
+
const policy = opts.userAccess ?? 'owner';
|
|
130
|
+
const hitMs = opts.cache?.hitMs ?? 300_000;
|
|
131
|
+
const missMs = opts.cache?.missMs ?? 30_000;
|
|
132
|
+
const cache = new LruCache(opts.cache?.maxEntries ?? 1_000);
|
|
133
|
+
const now = opts.now ?? Date.now;
|
|
134
|
+
const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
|
|
135
|
+
const sourceBudget = new RateLimiter(opts.introspectionsPerSourcePerMin ?? 60);
|
|
136
|
+
const resourceHost = new URL(opts.resource).host;
|
|
137
|
+
const introspect = opts.introspect ?? ((token) => introspectAtAuth(authBase(opts), token));
|
|
138
|
+
const unavailable = (c) => {
|
|
139
|
+
c.header('cache-control', 'no-store');
|
|
140
|
+
return c.json({ error: 'auth service unavailable' }, 503);
|
|
141
|
+
};
|
|
142
|
+
const rateLimited = (c) => {
|
|
143
|
+
c.header('cache-control', 'no-store');
|
|
144
|
+
return c.json({ error: 'rate limit exceeded' }, 429);
|
|
145
|
+
};
|
|
146
|
+
return createMiddleware(async (c, next) => {
|
|
147
|
+
const admit = (user) => {
|
|
148
|
+
if (!userAllowed(policy, user)) {
|
|
149
|
+
opts.logger?.warn('connector user access denied', {
|
|
150
|
+
email: user.email,
|
|
151
|
+
policy,
|
|
152
|
+
path: c.req.path,
|
|
153
|
+
});
|
|
154
|
+
c.header('cache-control', 'no-store');
|
|
155
|
+
return c.json({ error: 'not enabled for this account', code: ACCESS_DENIED_CODE, email: user.email }, 403);
|
|
156
|
+
}
|
|
157
|
+
if (!limiter.allow(user.email)) {
|
|
158
|
+
opts.logger?.warn('connector rate limit exceeded', { email: user.email, path: c.req.path });
|
|
159
|
+
return rateLimited(c);
|
|
160
|
+
}
|
|
161
|
+
// A copy per request: the cached identity is shared, and a Set typed
|
|
162
|
+
// read-only is still mutable at runtime.
|
|
163
|
+
c.set('connector', { ...user, scopes: new Set(user.scopes) });
|
|
164
|
+
return next();
|
|
165
|
+
};
|
|
166
|
+
if (authMode() === 'test') {
|
|
167
|
+
const email = c.req.header('x-test-connector-user');
|
|
168
|
+
if (!email)
|
|
169
|
+
return unauthorized(c, opts);
|
|
170
|
+
const scope = c.req.header('x-test-connector-scope');
|
|
171
|
+
return admit({
|
|
172
|
+
type: 'connector',
|
|
173
|
+
email,
|
|
174
|
+
role: parseRole(c.req.header('x-test-connector-role')) ?? 'invited',
|
|
175
|
+
scopes: scope === undefined ? new Set(opts.scopes) : parseScopes(scope),
|
|
176
|
+
clientId: 'test-client',
|
|
177
|
+
grantId: 'test-grant',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const header = c.req.header('authorization');
|
|
181
|
+
if (!header)
|
|
182
|
+
return unauthorized(c, opts);
|
|
183
|
+
const token = header.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
|
|
184
|
+
// A bearer of any other shape — including a service's OIDC token — is not
|
|
185
|
+
// ours to verify and is never forwarded or logged.
|
|
186
|
+
if (!token || !ACCESS_TOKEN_PATTERN.test(token))
|
|
187
|
+
return unauthorized(c, opts, 'invalid_token');
|
|
188
|
+
const key = createHash('sha256').update(token).digest('hex');
|
|
189
|
+
const cached = cache.get(key, now());
|
|
190
|
+
if (cached)
|
|
191
|
+
return cached.user ? admit(cached.user) : unauthorized(c, opts, 'invalid_token');
|
|
192
|
+
const reject = () => {
|
|
193
|
+
cache.set(key, { expires: now() + missMs });
|
|
194
|
+
return unauthorized(c, opts, 'invalid_token');
|
|
195
|
+
};
|
|
196
|
+
// Spent before the call, on every uncached token whatever the answer, so
|
|
197
|
+
// concurrent junk cannot overshoot it. Over budget is a 429, never a 401:
|
|
198
|
+
// a client holding a good token must not be told to discard it.
|
|
199
|
+
if (!sourceBudget.allow(forwardedClientIp(c, resourceHost))) {
|
|
200
|
+
opts.logger?.warn('connector introspection budget exhausted', { path: c.req.path });
|
|
201
|
+
return rateLimited(c);
|
|
202
|
+
}
|
|
203
|
+
let res;
|
|
204
|
+
try {
|
|
205
|
+
res = await introspect(token);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
opts.logger?.error('auth service unreachable for introspection', { error: err });
|
|
209
|
+
return unavailable(c);
|
|
210
|
+
}
|
|
211
|
+
if (res.status !== 200) {
|
|
212
|
+
opts.logger?.error('connector token introspection failed', { meta_status: res.status });
|
|
213
|
+
return unavailable(c);
|
|
214
|
+
}
|
|
215
|
+
const data = (await res.json().catch(() => null));
|
|
216
|
+
// Only an explicit `active: false` means "not valid". Any other answer
|
|
217
|
+
// without `active: true` is a protocol fault: fail closed, loudly, and
|
|
218
|
+
// without caching, so a broken auth service cannot sign every user out.
|
|
219
|
+
if (data?.active === false)
|
|
220
|
+
return reject();
|
|
221
|
+
if (data?.active !== true || Array.isArray(data)) {
|
|
222
|
+
opts.logger?.error('connector introspection answer is malformed');
|
|
223
|
+
return unavailable(c);
|
|
224
|
+
}
|
|
225
|
+
if (data.resource !== opts.resource) {
|
|
226
|
+
// The auth service only answers `active` to the resource's own service
|
|
227
|
+
// account, so this is reachable only by the always-admitted agent
|
|
228
|
+
// identity in development. Not valid here either way.
|
|
229
|
+
opts.logger?.warn('connector token is for another resource', { resource: data.resource });
|
|
230
|
+
return reject();
|
|
231
|
+
}
|
|
232
|
+
const role = parseRole(data.role);
|
|
233
|
+
const expMs = typeof data.exp === 'number' ? data.exp * 1000 : NaN;
|
|
234
|
+
if (!role ||
|
|
235
|
+
!nonEmptyString(data.email) ||
|
|
236
|
+
!nonEmptyString(data.client_id) ||
|
|
237
|
+
!nonEmptyString(data.grant_id) ||
|
|
238
|
+
!Number.isFinite(expMs)) {
|
|
239
|
+
opts.logger?.error('connector introspection answer is malformed');
|
|
240
|
+
return unavailable(c);
|
|
241
|
+
}
|
|
242
|
+
if (expMs <= now())
|
|
243
|
+
return reject();
|
|
244
|
+
const user = {
|
|
245
|
+
type: 'connector',
|
|
246
|
+
email: data.email,
|
|
247
|
+
name: typeof data.name === 'string' ? data.name : undefined,
|
|
248
|
+
userId: typeof data.user_id === 'string' ? data.user_id : undefined,
|
|
249
|
+
role,
|
|
250
|
+
scopes: parseScopes(data.scope),
|
|
251
|
+
clientId: data.client_id,
|
|
252
|
+
grantId: data.grant_id,
|
|
253
|
+
};
|
|
254
|
+
cache.set(key, { expires: Math.min(now() + hitMs, expMs), user });
|
|
255
|
+
return admit(user);
|
|
256
|
+
});
|
|
257
|
+
}
|
package/dist/s2s.d.ts
CHANGED
package/dist/s2s.js
CHANGED
|
@@ -47,6 +47,8 @@ export function grantAllows(grants, email, method, path) {
|
|
|
47
47
|
// loops and mischief, not a precision tool (per-instance state only).
|
|
48
48
|
export class RateLimiter {
|
|
49
49
|
perMinute;
|
|
50
|
+
// Insertion order is window-start order (a restarted window is re-inserted),
|
|
51
|
+
// so the oldest window is always first.
|
|
50
52
|
windows = new Map();
|
|
51
53
|
constructor(perMinute) {
|
|
52
54
|
this.perMinute = perMinute;
|
|
@@ -55,13 +57,31 @@ export class RateLimiter {
|
|
|
55
57
|
const now = Date.now();
|
|
56
58
|
const w = this.windows.get(key);
|
|
57
59
|
if (!w || now - w.start >= 60_000) {
|
|
60
|
+
this.windows.delete(key);
|
|
61
|
+
this.evict(now);
|
|
58
62
|
this.windows.set(key, { start: now, count: 1 });
|
|
59
|
-
return
|
|
63
|
+
return this.perMinute >= 1;
|
|
60
64
|
}
|
|
61
65
|
w.count++;
|
|
62
66
|
return w.count <= this.perMinute;
|
|
63
67
|
}
|
|
68
|
+
// Keys can be chosen by unauthenticated callers (a per-IP limiter on a
|
|
69
|
+
// public endpoint), so finished windows are dropped as new ones arrive and
|
|
70
|
+
// the map never exceeds RATE_LIMITER_MAX_KEYS: past that the oldest live
|
|
71
|
+
// window is forgotten, which only ever relaxes the limit for that key.
|
|
72
|
+
evict(now) {
|
|
73
|
+
for (const [key, w] of this.windows) {
|
|
74
|
+
if (now - w.start < 60_000 && this.windows.size < RATE_LIMITER_MAX_KEYS)
|
|
75
|
+
return;
|
|
76
|
+
this.windows.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
64
79
|
}
|
|
80
|
+
const RATE_LIMITER_MAX_KEYS = 10_000;
|
|
81
|
+
/** The auth service's OAuth token families (access, refresh, authorization
|
|
82
|
+
* code). They are opaque, never Google ID tokens, and are admitted only by
|
|
83
|
+
* oauth-resource.ts's middleware on an app's connector route. */
|
|
84
|
+
const CONNECTOR_TOKEN = /^pw(?:at|rt|ac)_/;
|
|
65
85
|
/** `message` with every occurrence of `token` — and of each of its
|
|
66
86
|
* dot-separated segments of 8+ chars, since some verifier messages quote a
|
|
67
87
|
* single segment — replaced by `<token>`, then truncated. */
|
|
@@ -85,6 +105,12 @@ export async function resolveS2sCaller(opts, headers, method, path) {
|
|
|
85
105
|
const m = headers.authorization?.match(/^Bearer (.+)$/i);
|
|
86
106
|
if (!m)
|
|
87
107
|
return { failure: { status: 401, error: 'missing bearer token' } };
|
|
108
|
+
if (CONNECTOR_TOKEN.test(m[1])) {
|
|
109
|
+
// A connector token on a route that takes service callers: refused here
|
|
110
|
+
// so it never reaches the verifier, whose errors quote the token.
|
|
111
|
+
opts.logger?.warn('connector token presented on a service route', { path });
|
|
112
|
+
return { failure: { status: 401, error: 'invalid token' } };
|
|
113
|
+
}
|
|
88
114
|
try {
|
|
89
115
|
const client = opts.verifyClient ?? defaultVerifyClient();
|
|
90
116
|
const ticket = await client.verifyIdToken({ idToken: m[1], audience: opts.audience });
|
|
@@ -145,6 +145,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
145
145
|
private halted;
|
|
146
146
|
private refetchInFlight;
|
|
147
147
|
private probeInFlight;
|
|
148
|
+
private probeRequested;
|
|
148
149
|
private resyncTimes;
|
|
149
150
|
/** Forward-gap events delivered while a resync is reconciling; drained
|
|
150
151
|
* after the bootstrap installs. Non-null exactly while reconciling. */
|
|
@@ -200,6 +201,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
200
201
|
private onBecameVisible;
|
|
201
202
|
private onOnline;
|
|
202
203
|
private wakeConnection;
|
|
204
|
+
private logConnectTimeout;
|
|
203
205
|
/** Drops the current socket, if any, without treating it as an outage, and
|
|
204
206
|
* connects at once. */
|
|
205
207
|
private replaceSocket;
|
|
@@ -226,6 +228,12 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
226
228
|
* (once per return); anything else takes the ladder. */
|
|
227
229
|
private takeQuickRetry;
|
|
228
230
|
private scheduleReconnect;
|
|
231
|
+
/** One HTTP round trip while the visible socket is down: delivers pending
|
|
232
|
+
* events (`postEvents`), then catches up — which applies their echoes, so
|
|
233
|
+
* the writes confirm — and doubles as the session probe (an expired
|
|
234
|
+
* session halts through the auth state). A WebSocket handshake can be
|
|
235
|
+
* stuck for minutes behind another one in the browser's per-IP queue while
|
|
236
|
+
* HTTP works at once (notes, 2026-10-01), so writes must not wait for it. */
|
|
229
237
|
private probeSession;
|
|
230
238
|
private startOutage;
|
|
231
239
|
/** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
|
|
@@ -27,6 +27,14 @@ const RETURN_REPORT_TIMEOUT_MS = 15_000;
|
|
|
27
27
|
* without reaching the server, while HTTP works at once; the ladder's 1 s
|
|
28
28
|
* step then made every such return take 1.5-2 s (reader, 2026-09-26). */
|
|
29
29
|
const RETURN_QUICK_RETRY_MS = 250;
|
|
30
|
+
/** A handshake that takes at least this long to open, or to fail on its own,
|
|
31
|
+
* is logged (INFO): it either sat in the browser's per-IP handshake queue or
|
|
32
|
+
* stalled itself. Chrome runs one WebSocket handshake per server IP:port at a
|
|
33
|
+
* time, across every tab and hostname; all fleet apps share the load
|
|
34
|
+
* balancer's IP, so one stalled handshake holds every app's sockets until it
|
|
35
|
+
* resolves (notes, 2026-10-01: four minutes). Fast failures (offline) are
|
|
36
|
+
* not logged. */
|
|
37
|
+
const SLOW_HANDSHAKE_MS = 3_000;
|
|
30
38
|
const SOCKET_CONNECTING = 0;
|
|
31
39
|
const SOCKET_OPEN = 1;
|
|
32
40
|
class LifecycleAbort {
|
|
@@ -71,6 +79,7 @@ export class SyncClient {
|
|
|
71
79
|
halted = false;
|
|
72
80
|
refetchInFlight = false;
|
|
73
81
|
probeInFlight = false;
|
|
82
|
+
probeRequested = false;
|
|
74
83
|
resyncTimes = [];
|
|
75
84
|
/** Forward-gap events delivered while a resync is reconciling; drained
|
|
76
85
|
* after the bootstrap installs. Non-null exactly while reconciling. */
|
|
@@ -105,6 +114,7 @@ export class SyncClient {
|
|
|
105
114
|
backgroundCatchups: 0,
|
|
106
115
|
backgroundCatchupFailures: 0,
|
|
107
116
|
backgroundSocketAttempts: 0,
|
|
117
|
+
httpPostedEvents: 0,
|
|
108
118
|
};
|
|
109
119
|
constructor(options) {
|
|
110
120
|
this.options = options;
|
|
@@ -222,6 +232,11 @@ export class SyncClient {
|
|
|
222
232
|
this.options.store.applyOptimistic(event);
|
|
223
233
|
this.armFailureTimer(event.eventGuid);
|
|
224
234
|
this.trySend(entry);
|
|
235
|
+
// A visible page in an outage delivers over HTTP at once rather than
|
|
236
|
+
// waiting for a handshake that may be stuck behind another one.
|
|
237
|
+
if (!this.isHidden() && this.outageStartedAt !== null && this.socket?.readyState !== SOCKET_OPEN) {
|
|
238
|
+
void this.probeSession();
|
|
239
|
+
}
|
|
225
240
|
this.updatePendingState();
|
|
226
241
|
this.options.onSafetyChange?.();
|
|
227
242
|
}
|
|
@@ -301,6 +316,7 @@ export class SyncClient {
|
|
|
301
316
|
backgroundCatchups: now.backgroundCatchups - since.backgroundCatchups,
|
|
302
317
|
backgroundCatchupFailures: now.backgroundCatchupFailures - since.backgroundCatchupFailures,
|
|
303
318
|
backgroundSocketAttempts: now.backgroundSocketAttempts - since.backgroundSocketAttempts,
|
|
319
|
+
httpPostedEvents: now.httpPostedEvents - since.httpPostedEvents,
|
|
304
320
|
};
|
|
305
321
|
}
|
|
306
322
|
onBecameHidden() {
|
|
@@ -379,12 +395,25 @@ export class SyncClient {
|
|
|
379
395
|
// closing is gone too. Waiting on either routes the returning user
|
|
380
396
|
// through the watchdog's close and a full backoff step (16-30 s of
|
|
381
397
|
// "Reconnecting…" observed on reader, 2026-09-24).
|
|
382
|
-
if (socket?.readyState === SOCKET_CONNECTING
|
|
383
|
-
this.options.runtime.now() - this.socketCreatedAt
|
|
384
|
-
|
|
398
|
+
if (socket?.readyState === SOCKET_CONNECTING) {
|
|
399
|
+
const socketAgeMs = this.options.runtime.now() - this.socketCreatedAt;
|
|
400
|
+
if (socketAgeMs < CONNECT_WATCHDOG_MS)
|
|
401
|
+
return;
|
|
402
|
+
// Its watchdog never ran: a throttled or frozen hidden page, the case
|
|
403
|
+
// that can hold the browser's handshake queue past 10 s.
|
|
404
|
+
this.logConnectTimeout(socketAgeMs, { replacedOnReturn: true });
|
|
385
405
|
}
|
|
386
406
|
this.replaceSocket();
|
|
387
407
|
}
|
|
408
|
+
logConnectTimeout(socketAgeMs, extra = {}) {
|
|
409
|
+
this.options.logger.info('sync connect timeout', {
|
|
410
|
+
socketAgeMs,
|
|
411
|
+
lateMs: Math.max(0, socketAgeMs - CONNECT_WATCHDOG_MS),
|
|
412
|
+
createdHidden: this.socketCreatedHidden,
|
|
413
|
+
hidden: this.isHidden(),
|
|
414
|
+
...extra,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
388
417
|
/** Drops the current socket, if any, without treating it as an outage, and
|
|
389
418
|
* connects at once. */
|
|
390
419
|
replaceSocket() {
|
|
@@ -422,9 +451,12 @@ export class SyncClient {
|
|
|
422
451
|
this.socketOpenedAt = null;
|
|
423
452
|
if (this.socketCreatedHidden)
|
|
424
453
|
this.counters.backgroundSocketAttempts++;
|
|
454
|
+
let killedByWatchdog = false;
|
|
425
455
|
this.watchdogTimer = this.options.runtime.setTimeout(() => {
|
|
426
456
|
if (socket.readyState !== SOCKET_OPEN) {
|
|
427
457
|
this.counters.watchdogKills++;
|
|
458
|
+
killedByWatchdog = true;
|
|
459
|
+
this.logConnectTimeout(this.options.runtime.now() - this.socketCreatedAt);
|
|
428
460
|
socket.close();
|
|
429
461
|
}
|
|
430
462
|
}, CONNECT_WATCHDOG_MS);
|
|
@@ -437,6 +469,14 @@ export class SyncClient {
|
|
|
437
469
|
this.options.runtime.clearTimeout(this.watchdogTimer);
|
|
438
470
|
this.watchdogTimer = null;
|
|
439
471
|
this.socketOpenedAt = this.now();
|
|
472
|
+
const openMs = this.socketOpenedAt - this.socketCreatedAt;
|
|
473
|
+
if (openMs >= SLOW_HANDSHAKE_MS) {
|
|
474
|
+
this.options.logger.info('sync connect slow', {
|
|
475
|
+
openMs,
|
|
476
|
+
createdHidden: this.socketCreatedHidden,
|
|
477
|
+
hidden: this.isHidden(),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
440
480
|
this.cancelBackgroundTick();
|
|
441
481
|
this.sendConnect();
|
|
442
482
|
this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
|
|
@@ -462,6 +502,15 @@ export class SyncClient {
|
|
|
462
502
|
this.socket = null;
|
|
463
503
|
if (this.halted || this.disposed)
|
|
464
504
|
return;
|
|
505
|
+
const socketAgeMs = this.now() - this.socketCreatedAt;
|
|
506
|
+
if (this.socketOpenedAt === null && !killedByWatchdog && socketAgeMs >= SLOW_HANDSHAKE_MS) {
|
|
507
|
+
this.options.logger.info('sync connect failed', {
|
|
508
|
+
socketAgeMs,
|
|
509
|
+
closeCode: event.code,
|
|
510
|
+
createdHidden: this.socketCreatedHidden,
|
|
511
|
+
hidden: this.isHidden(),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
465
514
|
if (event.code === CLOSE_NO_USER) {
|
|
466
515
|
this.options.authState.markBlocked();
|
|
467
516
|
this.halt();
|
|
@@ -550,6 +599,8 @@ export class SyncClient {
|
|
|
550
599
|
if (this.stopBeforeStoreMutation())
|
|
551
600
|
return;
|
|
552
601
|
for (const outcome of outcomes) {
|
|
602
|
+
if (outcome.status !== 'rejected')
|
|
603
|
+
this.counters.httpPostedEvents++;
|
|
553
604
|
if (outcome.status === 'rejected') {
|
|
554
605
|
this.handleRejection({
|
|
555
606
|
type: 'rejection',
|
|
@@ -729,8 +780,9 @@ export class SyncClient {
|
|
|
729
780
|
const delay = Math.ceil(delayMs / 1000);
|
|
730
781
|
this.reconnectAttempts++;
|
|
731
782
|
this.totalAttempts++;
|
|
732
|
-
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
|
|
783
|
+
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS || this.unsettledPending().length > 0) {
|
|
733
784
|
void this.probeSession();
|
|
785
|
+
}
|
|
734
786
|
let remaining = delay;
|
|
735
787
|
this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
|
|
736
788
|
this.countdownTimer = this.options.runtime.setInterval(() => {
|
|
@@ -747,11 +799,25 @@ export class SyncClient {
|
|
|
747
799
|
this.connect();
|
|
748
800
|
}, delayMs);
|
|
749
801
|
}
|
|
802
|
+
/** One HTTP round trip while the visible socket is down: delivers pending
|
|
803
|
+
* events (`postEvents`), then catches up — which applies their echoes, so
|
|
804
|
+
* the writes confirm — and doubles as the session probe (an expired
|
|
805
|
+
* session halts through the auth state). A WebSocket handshake can be
|
|
806
|
+
* stuck for minutes behind another one in the browser's per-IP queue while
|
|
807
|
+
* HTTP works at once (notes, 2026-10-01), so writes must not wait for it. */
|
|
750
808
|
async probeSession() {
|
|
751
|
-
if (this.halted || this.disposed
|
|
809
|
+
if (this.halted || this.disposed)
|
|
752
810
|
return;
|
|
811
|
+
if (this.probeInFlight) {
|
|
812
|
+
this.probeRequested = true;
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
753
815
|
this.probeInFlight = true;
|
|
816
|
+
this.probeRequested = false;
|
|
754
817
|
try {
|
|
818
|
+
await this.postPendingOverHttp();
|
|
819
|
+
if (this.stopBeforeStoreMutation())
|
|
820
|
+
return;
|
|
755
821
|
const result = await this.abortable((signal) => this.options.fetchCatchup(this.options.store.currentSeq(), signal));
|
|
756
822
|
if (this.stopBeforeStoreMutation())
|
|
757
823
|
return;
|
|
@@ -771,6 +837,11 @@ export class SyncClient {
|
|
|
771
837
|
finally {
|
|
772
838
|
this.probeInFlight = false;
|
|
773
839
|
}
|
|
840
|
+
// Something was submitted while this probe was in flight; deliver it
|
|
841
|
+
// too, unless the socket came back in the meantime.
|
|
842
|
+
if (this.probeRequested && this.socket?.readyState !== SOCKET_OPEN && !this.isHidden()) {
|
|
843
|
+
void this.probeSession();
|
|
844
|
+
}
|
|
774
845
|
}
|
|
775
846
|
startOutage(closeCode, wasClean = false) {
|
|
776
847
|
if (this.outageStartedAt !== null)
|
|
@@ -1126,6 +1197,7 @@ export class SyncClient {
|
|
|
1126
1197
|
hiddenMs: delta?.hiddenMs,
|
|
1127
1198
|
watchdogKills: delta?.watchdogKills,
|
|
1128
1199
|
backgroundCatchups: delta?.backgroundCatchups,
|
|
1200
|
+
httpPostedEvents: delta?.httpPostedEvents,
|
|
1129
1201
|
freezes: delta?.freezes,
|
|
1130
1202
|
resumes: delta?.resumes,
|
|
1131
1203
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.0",
|
|
4
4
|
"description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./dist/auth.d.ts",
|
|
38
38
|
"default": "./dist/auth.js"
|
|
39
39
|
},
|
|
40
|
+
"./oauth-resource": {
|
|
41
|
+
"types": "./dist/oauth-resource.d.ts",
|
|
42
|
+
"default": "./dist/oauth-resource.js"
|
|
43
|
+
},
|
|
40
44
|
"./db": {
|
|
41
45
|
"types": "./dist/db.d.ts",
|
|
42
46
|
"default": "./dist/db.js"
|