@hotmeshio/long-tail 0.24.1 → 0.24.2

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.
@@ -0,0 +1,15 @@
1
+ -- Migration 040: when a person last consented to a grant for an OAuth client
2
+ --
3
+ -- Registration needs no sign-in, so a client that never reaches consent is
4
+ -- deleted after the retention window. A client someone has consented to is
5
+ -- kept, so the app can always reconnect with the client id it holds.
6
+
7
+ ALTER TABLE lt_oauth_clients ADD COLUMN IF NOT EXISTS last_consent_at TIMESTAMPTZ;
8
+
9
+ -- Clients consented to before this migration: their newest grant.
10
+ UPDATE lt_oauth_clients c
11
+ SET last_consent_at = g.created_at
12
+ FROM (
13
+ SELECT client_id, MAX(created_at) AS created_at FROM lt_oauth_grants GROUP BY client_id
14
+ ) g
15
+ WHERE c.client_id = g.client_id AND c.last_consent_at IS NULL;
@@ -30,6 +30,10 @@ export declare const config: {
30
30
  JWT_SECRET: string;
31
31
  LT_SEED_SERVICE_KEYS: boolean;
32
32
  LT_OAUTH_ISSUER: string;
33
+ /** Spent, expired and revoked OAuth rows are deleted once older than this many days. */
34
+ OAUTH_RETENTION_DAYS: number;
35
+ /** Least time between one process's OAuth sweeps. A sweep runs on an OAuth write. */
36
+ OAUTH_SWEEP_SECONDS: number;
33
37
  HONEYCOMB_API_KEY: string;
34
38
  ROLE_ENFORCEMENT_CACHE_TTL_MS: number;
35
39
  LT_ANALYTICS_MAX_GROUPS: number;
@@ -45,6 +45,10 @@ exports.config = {
45
45
  LT_SEED_SERVICE_KEYS: process.env.LT_SEED_SERVICE_KEYS === 'true',
46
46
  // Enables the OAuth authorization server for /mcp when start() passes no auth.oauthServer.
47
47
  LT_OAUTH_ISSUER: process.env.LT_OAUTH_ISSUER || '',
48
+ /** Spent, expired and revoked OAuth rows are deleted once older than this many days. */
49
+ OAUTH_RETENTION_DAYS: positiveInt('OAUTH_RETENTION_DAYS', 7),
50
+ /** Least time between one process's OAuth sweeps. A sweep runs on an OAuth write. */
51
+ OAUTH_SWEEP_SECONDS: positiveInt('OAUTH_SWEEP_SECONDS', 600),
48
52
  HONEYCOMB_API_KEY: process.env.HONEYCOMB_API_KEY || '',
49
53
  // Resolver schema enforcement: TTL for the cached enforcing-role set and
50
54
  // latest role form schemas. Bounds cross-container staleness after a role
@@ -11,7 +11,7 @@ import type { LTMaintenanceConfig } from '../types/maintenance';
11
11
  * (preserves jdata, udata, jmark, hmark — exports always work)
12
12
  * 4. Hard-delete pruned jobs older than 180 days
13
13
  * 5. Delete OAuth server rows (expired codes and tokens, unused or revoked
14
- * grants, clients with no live grant) older than 7 days
14
+ * grants, clients never consented to) older than 7 days
15
15
  *
16
16
  * Conservative defaults — execution artifacts (activity inputs, stream
17
17
  * history) are needed for rich export data. Pruning too early strips
@@ -13,7 +13,7 @@ exports.defaultMaintenanceConfig = void 0;
13
13
  * (preserves jdata, udata, jmark, hmark — exports always work)
14
14
  * 4. Hard-delete pruned jobs older than 180 days
15
15
  * 5. Delete OAuth server rows (expired codes and tokens, unused or revoked
16
- * grants, clients with no live grant) older than 7 days
16
+ * grants, clients never consented to) older than 7 days
17
17
  *
18
18
  * Conservative defaults — execution artifacts (activity inputs, stream
19
19
  * history) are needed for rich export data. Pruning too early strips
@@ -1,3 +1,3 @@
1
- export { REFRESH_REUSE_GRACE_SECONDS, createSecret, hashSecret, pkceChallenge, registerClient, getClient, issueAuthorizationCode, exchangeAuthorizationCode, rotateRefreshToken, getLiveGrant, revokeByRefreshToken, revokeGrant, listGrants, pruneOAuthServer, type RevokedGrant, type OAuthPruneCounts, } from './store';
1
+ export { REFRESH_REUSE_GRACE_SECONDS, createSecret, hashSecret, pkceChallenge, registerClient, getClient, issueAuthorizationCode, exchangeAuthorizationCode, rotateRefreshToken, getLiveGrant, revokeByRefreshToken, revokeGrant, listGrants, pruneOAuthServer, sweepOAuthServer, type RevokedGrant, type OAuthPruneCounts, } from './store';
2
2
  export { signAccessToken, verifyAccessToken, accessTokenPrincipal, type AccessTokenTarget, type LTAccessTokenClaims, } from './access-token';
3
3
  export { MCP_SCOPES, PRESET_SCOPE, ACCESS_TOKEN_TYPE, OAUTH_PRINCIPAL_TYPE } from './constants';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OAUTH_PRINCIPAL_TYPE = exports.ACCESS_TOKEN_TYPE = exports.PRESET_SCOPE = exports.MCP_SCOPES = exports.accessTokenPrincipal = exports.verifyAccessToken = exports.signAccessToken = exports.pruneOAuthServer = exports.listGrants = exports.revokeGrant = exports.revokeByRefreshToken = exports.getLiveGrant = exports.rotateRefreshToken = exports.exchangeAuthorizationCode = exports.issueAuthorizationCode = exports.getClient = exports.registerClient = exports.pkceChallenge = exports.hashSecret = exports.createSecret = exports.REFRESH_REUSE_GRACE_SECONDS = void 0;
3
+ exports.OAUTH_PRINCIPAL_TYPE = exports.ACCESS_TOKEN_TYPE = exports.PRESET_SCOPE = exports.MCP_SCOPES = exports.accessTokenPrincipal = exports.verifyAccessToken = exports.signAccessToken = exports.sweepOAuthServer = exports.pruneOAuthServer = exports.listGrants = exports.revokeGrant = exports.revokeByRefreshToken = exports.getLiveGrant = exports.rotateRefreshToken = exports.exchangeAuthorizationCode = exports.issueAuthorizationCode = exports.getClient = exports.registerClient = exports.pkceChallenge = exports.hashSecret = exports.createSecret = exports.REFRESH_REUSE_GRACE_SECONDS = void 0;
4
4
  var store_1 = require("./store");
5
5
  Object.defineProperty(exports, "REFRESH_REUSE_GRACE_SECONDS", { enumerable: true, get: function () { return store_1.REFRESH_REUSE_GRACE_SECONDS; } });
6
6
  Object.defineProperty(exports, "createSecret", { enumerable: true, get: function () { return store_1.createSecret; } });
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "revokeByRefreshToken", { enumerable: true, get:
16
16
  Object.defineProperty(exports, "revokeGrant", { enumerable: true, get: function () { return store_1.revokeGrant; } });
17
17
  Object.defineProperty(exports, "listGrants", { enumerable: true, get: function () { return store_1.listGrants; } });
18
18
  Object.defineProperty(exports, "pruneOAuthServer", { enumerable: true, get: function () { return store_1.pruneOAuthServer; } });
19
+ Object.defineProperty(exports, "sweepOAuthServer", { enumerable: true, get: function () { return store_1.sweepOAuthServer; } });
19
20
  var access_token_1 = require("./access-token");
20
21
  Object.defineProperty(exports, "signAccessToken", { enumerable: true, get: function () { return access_token_1.signAccessToken; } });
21
22
  Object.defineProperty(exports, "verifyAccessToken", { enumerable: true, get: function () { return access_token_1.verifyAccessToken; } });
@@ -1,7 +1,7 @@
1
1
  export declare const INSERT_CLIENT = "\n INSERT INTO lt_oauth_clients (client_id, client_name, redirect_uris)\n VALUES ($1, $2, $3)\n RETURNING client_id, client_name, redirect_uris, created_at";
2
2
  export declare const GET_CLIENT = "\n SELECT client_id, client_name, redirect_uris, created_at\n FROM lt_oauth_clients WHERE client_id = $1";
3
- /** Create the grant and its authorization code together. */
4
- export declare const ISSUE_CODE = "\n WITH g AS (\n INSERT INTO lt_oauth_grants (user_id, client_id, policy, scope)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n )\n INSERT INTO lt_oauth_codes (code_hash, grant_id, redirect_uri, code_challenge, resource, expires_at)\n SELECT $5, g.id, $6, $7, $8, NOW() + make_interval(secs => $9) FROM g\n RETURNING grant_id";
3
+ /** Create the grant and its authorization code together, and stamp the client's consent. */
4
+ export declare const ISSUE_CODE = "\n WITH g AS (\n INSERT INTO lt_oauth_grants (user_id, client_id, policy, scope)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ),\n consented AS (\n UPDATE lt_oauth_clients SET last_consent_at = NOW() WHERE client_id = $2\n )\n INSERT INTO lt_oauth_codes (code_hash, grant_id, redirect_uri, code_challenge, resource, expires_at)\n SELECT $5, g.id, $6, $7, $8, NOW() + make_interval(secs => $9) FROM g\n RETURNING grant_id";
5
5
  /**
6
6
  * Consume a code and issue the first refresh token. The code must be unused,
7
7
  * unexpired, for this client and redirect URI, and match the PKCE challenge;
@@ -46,5 +46,8 @@ export declare const PRUNE_CODES = "\n DELETE FROM lt_oauth_codes WHERE expires
46
46
  * left with no refresh token and no code: never exchanged, or long unused.
47
47
  */
48
48
  export declare const PRUNE_GRANTS = "\n DELETE FROM lt_oauth_grants g\n WHERE g.revoked_at < NOW() - $1::interval\n OR (g.created_at < NOW() - $1::interval\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id)\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_codes c WHERE c.grant_id = g.id))";
49
- /** Clients registered longer ago than the window with no live grant. */
50
- export declare const PRUNE_CLIENTS = "\n DELETE FROM lt_oauth_clients c\n WHERE c.created_at < NOW() - $1::interval\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_grants g WHERE g.client_id = c.client_id AND g.revoked_at IS NULL)";
49
+ /**
50
+ * Clients registered longer ago than the window that no one ever consented to.
51
+ * A client with a consent is kept: the app holds its client id to reconnect.
52
+ */
53
+ export declare const PRUNE_CLIENTS = "\n DELETE FROM lt_oauth_clients c\n WHERE c.last_consent_at IS NULL AND c.created_at < NOW() - $1::interval\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_grants g WHERE g.client_id = c.client_id)";
@@ -17,12 +17,15 @@ exports.INSERT_CLIENT = `
17
17
  exports.GET_CLIENT = `
18
18
  SELECT client_id, client_name, redirect_uris, created_at
19
19
  FROM lt_oauth_clients WHERE client_id = $1`;
20
- /** Create the grant and its authorization code together. */
20
+ /** Create the grant and its authorization code together, and stamp the client's consent. */
21
21
  exports.ISSUE_CODE = `
22
22
  WITH g AS (
23
23
  INSERT INTO lt_oauth_grants (user_id, client_id, policy, scope)
24
24
  VALUES ($1, $2, $3, $4)
25
25
  RETURNING id
26
+ ),
27
+ consented AS (
28
+ UPDATE lt_oauth_clients SET last_consent_at = NOW() WHERE client_id = $2
26
29
  )
27
30
  INSERT INTO lt_oauth_codes (code_hash, grant_id, redirect_uri, code_challenge, resource, expires_at)
28
31
  SELECT $5, g.id, $6, $7, $8, NOW() + make_interval(secs => $9) FROM g
@@ -149,8 +152,11 @@ exports.PRUNE_GRANTS = `
149
152
  OR (g.created_at < NOW() - $1::interval
150
153
  AND NOT EXISTS (SELECT 1 FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id)
151
154
  AND NOT EXISTS (SELECT 1 FROM lt_oauth_codes c WHERE c.grant_id = g.id))`;
152
- /** Clients registered longer ago than the window with no live grant. */
155
+ /**
156
+ * Clients registered longer ago than the window that no one ever consented to.
157
+ * A client with a consent is kept: the app holds its client id to reconnect.
158
+ */
153
159
  exports.PRUNE_CLIENTS = `
154
160
  DELETE FROM lt_oauth_clients c
155
- WHERE c.created_at < NOW() - $1::interval
156
- AND NOT EXISTS (SELECT 1 FROM lt_oauth_grants g WHERE g.client_id = c.client_id AND g.revoked_at IS NULL)`;
161
+ WHERE c.last_consent_at IS NULL AND c.created_at < NOW() - $1::interval
162
+ AND NOT EXISTS (SELECT 1 FROM lt_oauth_grants g WHERE g.client_id = c.client_id)`;
@@ -82,7 +82,14 @@ export interface OAuthPruneCounts {
82
82
  /**
83
83
  * Delete what the authorization server no longer needs, older than the
84
84
  * window (a Postgres interval): spent and expired refresh tokens and codes,
85
- * then grants with neither left or revoked, then clients with no live grant.
85
+ * then grants with neither left or revoked, then clients never consented to.
86
86
  * Each step reads what the one before it left.
87
87
  */
88
88
  export declare function pruneOAuthServer(olderThan: string): Promise<OAuthPruneCounts>;
89
+ /**
90
+ * Delete what the server no longer needs, older than OAUTH_RETENTION_DAYS.
91
+ * Each OAuth write calls it, and it runs at most once per OAUTH_SWEEP_SECONDS
92
+ * in a process, so the tables stay bounded with or without a maintenance
93
+ * schedule. A failed sweep is logged and never fails the write.
94
+ */
95
+ export declare function sweepOAuthServer(): Promise<void>;
@@ -47,8 +47,11 @@ exports.revokeByRefreshToken = revokeByRefreshToken;
47
47
  exports.revokeGrant = revokeGrant;
48
48
  exports.listGrants = listGrants;
49
49
  exports.pruneOAuthServer = pruneOAuthServer;
50
+ exports.sweepOAuthServer = sweepOAuthServer;
50
51
  const crypto = __importStar(require("crypto"));
51
52
  const db_1 = require("../../../lib/db");
53
+ const logger_1 = require("../../../lib/logger");
54
+ const config_1 = require("../../../modules/config");
52
55
  const uuid_1 = require("../../../lib/uuid");
53
56
  const sql_1 = require("./sql");
54
57
  /** A refresh token reused within this many seconds is treated as a client retry. */
@@ -69,6 +72,7 @@ async function registerClient(input) {
69
72
  const { rows } = await (0, db_1.getPool)().query(sql_1.INSERT_CLIENT, [
70
73
  `ltc_${createSecret()}`, input.clientName ?? null, input.redirectUris,
71
74
  ]);
75
+ void sweepOAuthServer();
72
76
  return rows[0];
73
77
  }
74
78
  /** A client id as registerClient issues it; anything else names no client and never reaches SQL. */
@@ -90,6 +94,7 @@ async function issueAuthorizationCode(input) {
90
94
  input.userId, input.clientId, input.policy, input.scope,
91
95
  hashSecret(code), input.redirectUri, input.codeChallenge, input.resource ?? null, input.ttlSeconds,
92
96
  ]);
97
+ void sweepOAuthServer();
93
98
  return { code, grantId: rows[0].grant_id };
94
99
  }
95
100
  /**
@@ -110,6 +115,7 @@ async function exchangeAuthorizationCode(input) {
110
115
  await pool.query(sql_1.REVOKE_ON_CODE_REUSE, [codeHash]);
111
116
  return null;
112
117
  }
118
+ void sweepOAuthServer();
113
119
  const { resource, ...snapshot } = rows[0];
114
120
  return { snapshot, resource, refreshToken };
115
121
  }
@@ -124,8 +130,10 @@ async function rotateRefreshToken(input) {
124
130
  const next = createSecret();
125
131
  const oldHash = hashSecret(input.refreshToken);
126
132
  const { rows } = await pool.query(sql_1.ROTATE_REFRESH_TOKEN, [oldHash, hashSecret(next), input.refreshTtlSeconds, input.clientId]);
127
- if (rows[0])
133
+ if (rows[0]) {
134
+ void sweepOAuthServer();
128
135
  return { snapshot: rows[0], refreshToken: next };
136
+ }
129
137
  const reuse = await pool.query(sql_1.REVOKE_ON_REFRESH_REUSE, [oldHash, exports.REFRESH_REUSE_GRACE_SECONDS]);
130
138
  return { snapshot: null, revoked: reuse.rows[0] ?? null };
131
139
  }
@@ -158,7 +166,7 @@ async function listGrants(userId) {
158
166
  /**
159
167
  * Delete what the authorization server no longer needs, older than the
160
168
  * window (a Postgres interval): spent and expired refresh tokens and codes,
161
- * then grants with neither left or revoked, then clients with no live grant.
169
+ * then grants with neither left or revoked, then clients never consented to.
162
170
  * Each step reads what the one before it left.
163
171
  */
164
172
  async function pruneOAuthServer(olderThan) {
@@ -169,3 +177,24 @@ async function pruneOAuthServer(olderThan) {
169
177
  const clients = (await pool.query(sql_1.PRUNE_CLIENTS, [olderThan])).rowCount ?? 0;
170
178
  return { refreshTokens, codes, grants, clients };
171
179
  }
180
+ let nextSweepAt = 0;
181
+ /**
182
+ * Delete what the server no longer needs, older than OAUTH_RETENTION_DAYS.
183
+ * Each OAuth write calls it, and it runs at most once per OAUTH_SWEEP_SECONDS
184
+ * in a process, so the tables stay bounded with or without a maintenance
185
+ * schedule. A failed sweep is logged and never fails the write.
186
+ */
187
+ async function sweepOAuthServer() {
188
+ const now = Date.now();
189
+ if (now < nextSweepAt)
190
+ return;
191
+ nextSweepAt = now + config_1.config.OAUTH_SWEEP_SECONDS * 1000;
192
+ try {
193
+ const counts = await pruneOAuthServer(`${config_1.config.OAUTH_RETENTION_DAYS} days`);
194
+ if (Object.values(counts).some(Boolean))
195
+ logger_1.loggerRegistry.info(`[lt-oauth] swept: ${JSON.stringify(counts)}`);
196
+ }
197
+ catch (err) {
198
+ logger_1.loggerRegistry.warn(`[lt-oauth] sweep failed: ${err?.message}`);
199
+ }
200
+ }
@@ -107,7 +107,7 @@ async function executeRule(appId, rule) {
107
107
  break;
108
108
  }
109
109
  // Drop the authorization server's dead rows: spent or expired tokens and
110
- // codes, grants with neither left or long revoked, clients with no live grant.
110
+ // codes, grants with neither left or long revoked, clients never consented to.
111
111
  case rule.target === 'oauth' && rule.action === 'delete': {
112
112
  const { pruneOAuthServer } = await Promise.resolve().then(() => __importStar(require('../auth/oauth-server')));
113
113
  const counts = await pruneOAuthServer(rule.olderThan);
@@ -62,7 +62,7 @@ Six categories of data are subject to maintenance:
62
62
  | **Entity jobs** | Job rows where `entity` is set. These represent actual workflow instances -- the rows that back `Durable.Client.workflow.search()` calls and execution exports. Deleting them removes the workflow record entirely. |
63
63
  | **Pruned jobs** | Entity jobs that have already had their execution artifacts stripped (the `pruned_at` column is not `NULL`). These retain core data but no longer carry execution scaffolding. They can be hard-deleted after a longer retention window. |
64
64
  | **Terminal escalations** | Escalation rows in a terminal status (`resolved`, `cancelled`, `expired`). Terminal rows are inert audit records -- every engine state transition guards on `status = 'pending'`, so aging them out is safe for live waiters, claims, and signal delivery. The engine-owned prune deletes in atomic batches until the retention horizon is drained. Pending rows are never touched. |
65
- | **OAuth server rows** | The `/mcp` authorization server's records. Refresh tokens and authorization codes past expiry, or rotated out, longer ago than the window; then grants left with neither, or revoked longer ago than the window; then clients registered longer ago than the window with no live grant. A client still holding a live refresh token keeps its grant and its registration. |
65
+ | **OAuth server rows** | The `/mcp` authorization server's records. Refresh tokens and authorization codes past expiry, or rotated out, longer ago than the window; then grants left with neither, or revoked longer ago than the window; then clients registered longer ago than the window that no one ever consented to. A client still holding a live refresh token keeps its grant, and a client anyone has consented to keeps its registration, so the app can reconnect with the client id it holds. |
66
66
 
67
67
  ## Prune vs. delete
68
68
 
@@ -97,9 +97,9 @@ The default configuration (`modules/maintenance.ts`, enabled with `maintenance:
97
97
  | 2. Delete transient jobs older than 30 days | Removes activity-level bookkeeping rows that have no associated workflow entity. |
98
98
  | 3. Prune entity jobs older than 30 days | Strips execution artifacts from workflow instances while preserving core data. Exports and search remain functional. |
99
99
  | 4. Delete pruned jobs older than 180 days | Hard-deletes workflow instances that were pruned at least 180 days ago. This is the final removal. |
100
- | 5. Delete OAuth server rows older than 7 days | Removes expired codes and tokens, unused or revoked grants, and clients with no live grant. |
100
+ | 5. Delete OAuth server rows older than 7 days | Removes expired codes and tokens, unused or revoked grants, and clients never consented to. |
101
101
 
102
- A custom `rules` list replaces the defaults, so include the `oauth` rule when the OAuth server is enabled.
102
+ A custom `rules` list replaces the defaults. The `oauth` rule is optional: the OAuth server sweeps its own tables as it is used (see [Connect with OAuth](mcp.md#connect-with-oauth)), and the rule adds a sweep on this schedule with its own window.
103
103
 
104
104
  Rules execute sequentially. If one fails, the remaining rules still run; failures are logged and do not halt the cycle.
105
105
 
package/docs/mcp.md CHANGED
@@ -207,6 +207,8 @@ Allow as me appears only when it grants more than read-only: when you can write
207
207
 
208
208
  Access tokens last 5 minutes; the client refreshes them silently with a refresh token that rotates on each use and lasts 30 days. Every request reads the grant: a client acts with your roles as they are now, and a disconnected app, a revoked token or a deactivated account stops working on the next request. Disconnect a client under **Connected apps** in the user menu; the same happens when the client revokes its token.
209
209
 
210
+ The server keeps its tables bounded on its own. Each process sweeps them on an OAuth write, at most once every `OAUTH_SWEEP_SECONDS` (default 600), deleting rows dead for longer than `OAUTH_RETENTION_DAYS` (default 7): spent and expired codes and refresh tokens, revoked or unused grants, and clients registered but never consented to. A live grant and its current refresh token are kept, and so is any client someone has consented to.
211
+
210
212
  **Enable it** with `auth.oauthServer` in `start()`, or `LT_OAUTH_ISSUER` in the environment:
211
213
 
212
214
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",