@hotmeshio/long-tail 0.13.0 → 0.13.1

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.
@@ -1,12 +1,15 @@
1
- import type { Request } from 'express';
1
+ import type { Request, Response } from 'express';
2
2
  import type { LTApiResult } from '../types/sdk';
3
3
  /**
4
4
  * Exchange host authentication for a Long Tail JWT.
5
5
  *
6
- * Calls `sso.resolve(req)` to extract the host identity, JIT provisions
6
+ * Calls `sso.resolve(req, res)` to extract the host identity, JIT provisions
7
7
  * the user in `lt_users`, and returns a signed JWT the dashboard can
8
8
  * store for subsequent API calls.
9
9
  *
10
10
  * No request body required — the host's cookies/headers carry the auth.
11
+ * `res` gives cookie-owning hosts the response handle at the exchange
12
+ * boundary (login and every keepalive beat) to set/refresh their session
13
+ * cookie — headers only; the route writes the response body.
11
14
  */
12
- export declare function exchangeSSO(req: Request): Promise<LTApiResult>;
15
+ export declare function exchangeSSO(req: Request, res?: Response): Promise<LTApiResult>;
@@ -7,19 +7,22 @@ const auth_1 = require("../modules/auth");
7
7
  /**
8
8
  * Exchange host authentication for a Long Tail JWT.
9
9
  *
10
- * Calls `sso.resolve(req)` to extract the host identity, JIT provisions
10
+ * Calls `sso.resolve(req, res)` to extract the host identity, JIT provisions
11
11
  * the user in `lt_users`, and returns a signed JWT the dashboard can
12
12
  * store for subsequent API calls.
13
13
  *
14
14
  * No request body required — the host's cookies/headers carry the auth.
15
+ * `res` gives cookie-owning hosts the response handle at the exchange
16
+ * boundary (login and every keepalive beat) to set/refresh their session
17
+ * cookie — headers only; the route writes the response body.
15
18
  */
16
- async function exchangeSSO(req) {
19
+ async function exchangeSSO(req, res) {
17
20
  try {
18
21
  const ssoConfig = (0, sso_1.getSSOConfig)();
19
22
  if (!ssoConfig) {
20
23
  return { status: 404, error: 'SSO not configured' };
21
24
  }
22
- const identity = await ssoConfig.resolve(req);
25
+ const identity = await ssoConfig.resolve(req, res);
23
26
  if (!identity) {
24
27
  return { status: 401, error: 'Host authentication required' };
25
28
  }
@@ -43,6 +43,7 @@ exports.signToken = signToken;
43
43
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
44
44
  const config_1 = require("./config");
45
45
  const sso_1 = require("./sso");
46
+ const logger_1 = require("../lib/logger");
46
47
  const user_1 = require("../services/user");
47
48
  const sso_provision_1 = require("../services/user/sso-provision");
48
49
  const bot_api_key_1 = require("../services/auth/bot-api-key");
@@ -156,7 +157,10 @@ const requireAuth = async (req, res, next) => {
156
157
  const mw = _authMiddleware || createAuthMiddleware(new JwtAuthAdapter());
157
158
  return mw(req, res, next);
158
159
  }
159
- // SSO fallback: no Bearer, but host may have authenticated via cookies/headers
160
+ // SSO fallback: no Bearer, but host may have authenticated via cookies/headers.
161
+ // `res` is deliberately NOT passed to resolve here: this path runs on every
162
+ // cookie-bearing request, and ambient API traffic must never slide the host
163
+ // session — only the explicit exchange (login + gated keepalive beat) does.
160
164
  const ssoConfig = (0, sso_1.getSSOConfig)();
161
165
  if (ssoConfig) {
162
166
  try {
@@ -177,8 +181,10 @@ const requireAuth = async (req, res, next) => {
177
181
  return next();
178
182
  }
179
183
  }
180
- catch {
181
- // SSO resolve failed — fall through to 401
184
+ catch (err) {
185
+ // SSO resolve failed — fall through to 401, but leave a trace so host
186
+ // resolve errors are diagnosable.
187
+ logger_1.loggerRegistry.debug(`[long-tail] sso resolve failed on auth fallback: ${err?.message}`);
182
188
  }
183
189
  }
184
190
  // No Bearer, no SSO — delegate to standard middleware (returns 401)
@@ -45,7 +45,12 @@ const router = (0, express_1.Router)();
45
45
  * login form with a transparent token exchange.
46
46
  */
47
47
  router.post('/sso', async (req, res) => {
48
- const result = await api.exchangeSSO(req);
48
+ const result = await api.exchangeSSO(req, res);
49
+ // A host's resolve may only set headers on `res`; if one violates the
50
+ // contract and writes the response, degrade to a skipped beat instead of
51
+ // a headers-after-send crash.
52
+ if (res.headersSent)
53
+ return;
49
54
  res.status(result.status).json(result.data ?? { error: result.error });
50
55
  });
51
56
  exports.default = router;
@@ -1,4 +1,4 @@
1
1
  export { listScanSchemes, getScanScheme, listScanRules, getScanRule, } from './read';
2
- export { upsertScanScheme, deleteScanScheme, upsertScanRule, deleteScanRule, seedScanScheme, seedScanRule, type ScanSchemeInput, type ScanRuleInput, } from './write';
2
+ export { upsertScanScheme, deleteScanScheme, upsertScanRule, deleteScanRule, seedScanScheme, seedScanRule, applyScanScheme, applyScanRule, type ScanSchemeInput, type ScanRuleInput, } from './write';
3
3
  export { parseScanCode, interpolateScanTemplate, type ScanParseResult, type ScanParseFailure, type ScanTemplateContext, } from './parse';
4
4
  export { assertValidScheme, assertValidSteps } from './validate';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.assertValidSteps = exports.assertValidScheme = exports.interpolateScanTemplate = exports.parseScanCode = exports.seedScanRule = exports.seedScanScheme = exports.deleteScanRule = exports.upsertScanRule = exports.deleteScanScheme = exports.upsertScanScheme = exports.getScanRule = exports.listScanRules = exports.getScanScheme = exports.listScanSchemes = void 0;
3
+ exports.assertValidSteps = exports.assertValidScheme = exports.interpolateScanTemplate = exports.parseScanCode = exports.applyScanRule = exports.applyScanScheme = exports.seedScanRule = exports.seedScanScheme = exports.deleteScanRule = exports.upsertScanRule = exports.deleteScanScheme = exports.upsertScanScheme = exports.getScanRule = exports.listScanRules = exports.getScanScheme = exports.listScanSchemes = void 0;
4
4
  var read_1 = require("./read");
5
5
  Object.defineProperty(exports, "listScanSchemes", { enumerable: true, get: function () { return read_1.listScanSchemes; } });
6
6
  Object.defineProperty(exports, "getScanScheme", { enumerable: true, get: function () { return read_1.getScanScheme; } });
@@ -13,6 +13,8 @@ Object.defineProperty(exports, "upsertScanRule", { enumerable: true, get: functi
13
13
  Object.defineProperty(exports, "deleteScanRule", { enumerable: true, get: function () { return write_1.deleteScanRule; } });
14
14
  Object.defineProperty(exports, "seedScanScheme", { enumerable: true, get: function () { return write_1.seedScanScheme; } });
15
15
  Object.defineProperty(exports, "seedScanRule", { enumerable: true, get: function () { return write_1.seedScanRule; } });
16
+ Object.defineProperty(exports, "applyScanScheme", { enumerable: true, get: function () { return write_1.applyScanScheme; } });
17
+ Object.defineProperty(exports, "applyScanRule", { enumerable: true, get: function () { return write_1.applyScanRule; } });
16
18
  var parse_1 = require("./parse");
17
19
  Object.defineProperty(exports, "parseScanCode", { enumerable: true, get: function () { return parse_1.parseScanCode; } });
18
20
  Object.defineProperty(exports, "interpolateScanTemplate", { enumerable: true, get: function () { return parse_1.interpolateScanTemplate; } });
@@ -8,3 +8,10 @@ export declare const UPSERT_ACTION = "INSERT INTO lt_config_scan_actions\n (sch
8
8
  export declare const DELETE_ACTION = "DELETE FROM lt_config_scan_actions WHERE scheme_version = $1 AND category = $2";
9
9
  export declare const SEED_SCHEME = "INSERT INTO lt_config_scan_schemes\n (version, name, description, target_facet, encoding, delimiter, target_length,\n kind, grant_ttl_seconds, grant_max_uses, enabled)\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\nON CONFLICT (version) DO NOTHING";
10
10
  export declare const SEED_ACTION = "INSERT INTO lt_config_scan_actions\n (scheme_version, category, name, steps, fallback, not_primed, enabled)\nVALUES ($1, $2, $3, $4, $5, $6, $7)\nON CONFLICT (scheme_version, category) DO NOTHING";
11
+ /**
12
+ * Apply — same column set as the upsert, guarded by IS DISTINCT FROM so an
13
+ * unchanged declaration is a zero-row no-op. `(xmax = 0)` distinguishes
14
+ * insert from update. jsonb columns compare content-wise in SQL.
15
+ */
16
+ export declare const APPLY_SCHEME = "INSERT INTO lt_config_scan_schemes\n (version, name, description, target_facet, encoding, delimiter, target_length,\n kind, grant_ttl_seconds, grant_max_uses, enabled)\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\nON CONFLICT (version) DO UPDATE SET\n name = EXCLUDED.name,\n description = EXCLUDED.description,\n target_facet = EXCLUDED.target_facet,\n encoding = EXCLUDED.encoding,\n delimiter = EXCLUDED.delimiter,\n target_length = EXCLUDED.target_length,\n kind = EXCLUDED.kind,\n grant_ttl_seconds = EXCLUDED.grant_ttl_seconds,\n grant_max_uses = EXCLUDED.grant_max_uses,\n enabled = EXCLUDED.enabled,\n updated_at = NOW()\nWHERE (lt_config_scan_schemes.name, lt_config_scan_schemes.description,\n lt_config_scan_schemes.target_facet, lt_config_scan_schemes.encoding,\n lt_config_scan_schemes.delimiter, lt_config_scan_schemes.target_length,\n lt_config_scan_schemes.kind, lt_config_scan_schemes.grant_ttl_seconds,\n lt_config_scan_schemes.grant_max_uses, lt_config_scan_schemes.enabled)\n IS DISTINCT FROM\n (EXCLUDED.name, EXCLUDED.description, EXCLUDED.target_facet,\n EXCLUDED.encoding, EXCLUDED.delimiter, EXCLUDED.target_length,\n EXCLUDED.kind, EXCLUDED.grant_ttl_seconds, EXCLUDED.grant_max_uses,\n EXCLUDED.enabled)\nRETURNING (xmax = 0) AS inserted";
17
+ export declare const APPLY_ACTION = "INSERT INTO lt_config_scan_actions\n (scheme_version, category, name, steps, fallback, not_primed, enabled)\nVALUES ($1, $2, $3, $4, $5, $6, $7)\nON CONFLICT (scheme_version, category) DO UPDATE SET\n name = EXCLUDED.name,\n steps = EXCLUDED.steps,\n fallback = EXCLUDED.fallback,\n not_primed = EXCLUDED.not_primed,\n enabled = EXCLUDED.enabled,\n updated_at = NOW()\nWHERE (lt_config_scan_actions.name, lt_config_scan_actions.steps,\n lt_config_scan_actions.fallback, lt_config_scan_actions.not_primed,\n lt_config_scan_actions.enabled)\n IS DISTINCT FROM\n (EXCLUDED.name, EXCLUDED.steps, EXCLUDED.fallback,\n EXCLUDED.not_primed, EXCLUDED.enabled)\nRETURNING (xmax = 0) AS inserted";
@@ -3,7 +3,7 @@
3
3
  // Read queries //
4
4
  // ------------------------------------------------------------------ //
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SEED_ACTION = exports.SEED_SCHEME = exports.DELETE_ACTION = exports.UPSERT_ACTION = exports.DELETE_SCHEME = exports.UPSERT_SCHEME = exports.GET_ACTION = exports.LIST_ACTIONS = exports.GET_SCHEME = exports.LIST_SCHEMES = void 0;
6
+ exports.APPLY_ACTION = exports.APPLY_SCHEME = exports.SEED_ACTION = exports.SEED_SCHEME = exports.DELETE_ACTION = exports.UPSERT_ACTION = exports.DELETE_SCHEME = exports.UPSERT_SCHEME = exports.GET_ACTION = exports.LIST_ACTIONS = exports.GET_SCHEME = exports.LIST_SCHEMES = void 0;
7
7
  exports.LIST_SCHEMES = `\
8
8
  SELECT * FROM lt_config_scan_schemes ORDER BY version`;
9
9
  exports.GET_SCHEME = `\
@@ -61,3 +61,57 @@ INSERT INTO lt_config_scan_actions
61
61
  (scheme_version, category, name, steps, fallback, not_primed, enabled)
62
62
  VALUES ($1, $2, $3, $4, $5, $6, $7)
63
63
  ON CONFLICT (scheme_version, category) DO NOTHING`;
64
+ // ------------------------------------------------------------------ //
65
+ // Apply (code-owned startup pass — code is source of truth) //
66
+ // ------------------------------------------------------------------ //
67
+ /**
68
+ * Apply — same column set as the upsert, guarded by IS DISTINCT FROM so an
69
+ * unchanged declaration is a zero-row no-op. `(xmax = 0)` distinguishes
70
+ * insert from update. jsonb columns compare content-wise in SQL.
71
+ */
72
+ exports.APPLY_SCHEME = `\
73
+ INSERT INTO lt_config_scan_schemes
74
+ (version, name, description, target_facet, encoding, delimiter, target_length,
75
+ kind, grant_ttl_seconds, grant_max_uses, enabled)
76
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
77
+ ON CONFLICT (version) DO UPDATE SET
78
+ name = EXCLUDED.name,
79
+ description = EXCLUDED.description,
80
+ target_facet = EXCLUDED.target_facet,
81
+ encoding = EXCLUDED.encoding,
82
+ delimiter = EXCLUDED.delimiter,
83
+ target_length = EXCLUDED.target_length,
84
+ kind = EXCLUDED.kind,
85
+ grant_ttl_seconds = EXCLUDED.grant_ttl_seconds,
86
+ grant_max_uses = EXCLUDED.grant_max_uses,
87
+ enabled = EXCLUDED.enabled,
88
+ updated_at = NOW()
89
+ WHERE (lt_config_scan_schemes.name, lt_config_scan_schemes.description,
90
+ lt_config_scan_schemes.target_facet, lt_config_scan_schemes.encoding,
91
+ lt_config_scan_schemes.delimiter, lt_config_scan_schemes.target_length,
92
+ lt_config_scan_schemes.kind, lt_config_scan_schemes.grant_ttl_seconds,
93
+ lt_config_scan_schemes.grant_max_uses, lt_config_scan_schemes.enabled)
94
+ IS DISTINCT FROM
95
+ (EXCLUDED.name, EXCLUDED.description, EXCLUDED.target_facet,
96
+ EXCLUDED.encoding, EXCLUDED.delimiter, EXCLUDED.target_length,
97
+ EXCLUDED.kind, EXCLUDED.grant_ttl_seconds, EXCLUDED.grant_max_uses,
98
+ EXCLUDED.enabled)
99
+ RETURNING (xmax = 0) AS inserted`;
100
+ exports.APPLY_ACTION = `\
101
+ INSERT INTO lt_config_scan_actions
102
+ (scheme_version, category, name, steps, fallback, not_primed, enabled)
103
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
104
+ ON CONFLICT (scheme_version, category) DO UPDATE SET
105
+ name = EXCLUDED.name,
106
+ steps = EXCLUDED.steps,
107
+ fallback = EXCLUDED.fallback,
108
+ not_primed = EXCLUDED.not_primed,
109
+ enabled = EXCLUDED.enabled,
110
+ updated_at = NOW()
111
+ WHERE (lt_config_scan_actions.name, lt_config_scan_actions.steps,
112
+ lt_config_scan_actions.fallback, lt_config_scan_actions.not_primed,
113
+ lt_config_scan_actions.enabled)
114
+ IS DISTINCT FROM
115
+ (EXCLUDED.name, EXCLUDED.steps, EXCLUDED.fallback,
116
+ EXCLUDED.not_primed, EXCLUDED.enabled)
117
+ RETURNING (xmax = 0) AS inserted`;
@@ -28,3 +28,11 @@ export declare function deleteScanRule(schemeVersion: number, category: string):
28
28
  /** Insert-if-absent seeding — DB is the source of truth, never overwrite. */
29
29
  export declare function seedScanScheme(input: ScanSchemeInput): Promise<boolean>;
30
30
  export declare function seedScanRule(input: ScanRuleInput): Promise<boolean>;
31
+ /**
32
+ * Apply a scheme declaration at startup (code is source of truth). Same
33
+ * validation as the upsert; the IS DISTINCT FROM guard makes an unchanged
34
+ * declaration a zero-row no-op.
35
+ */
36
+ export declare function applyScanScheme(input: ScanSchemeInput): Promise<'applied' | 'unchanged'>;
37
+ /** Apply a rule declaration at startup (code is source of truth). */
38
+ export declare function applyScanRule(input: ScanRuleInput): Promise<'applied' | 'unchanged'>;
@@ -6,6 +6,8 @@ exports.upsertScanRule = upsertScanRule;
6
6
  exports.deleteScanRule = deleteScanRule;
7
7
  exports.seedScanScheme = seedScanScheme;
8
8
  exports.seedScanRule = seedScanRule;
9
+ exports.applyScanScheme = applyScanScheme;
10
+ exports.applyScanRule = applyScanRule;
9
11
  const db_1 = require("../../lib/db");
10
12
  const types_1 = require("../../types");
11
13
  const sql_1 = require("./sql");
@@ -55,7 +57,8 @@ async function deleteScanScheme(version) {
55
57
  const { rowCount } = await (0, db_1.getPool)().query(sql_1.DELETE_SCHEME, [version]);
56
58
  return (rowCount ?? 0) > 0;
57
59
  }
58
- async function upsertScanRule(input) {
60
+ /** Shared rule-input validation — the upsert and startup-apply paths enforce the same contract. */
61
+ async function assertValidRuleInput(input) {
59
62
  if (!/^[0-9]$/.test(input.category)) {
60
63
  throw new Error('category must be a single digit (0-9)');
61
64
  }
@@ -65,6 +68,9 @@ async function upsertScanRule(input) {
65
68
  if (input.steps.length === 0 && !input.fallback?.markdown && !input.fallback?.route) {
66
69
  throw new Error('a rule needs at least one step or a fallback');
67
70
  }
71
+ }
72
+ async function upsertScanRule(input) {
73
+ await assertValidRuleInput(input);
68
74
  const { rows } = await (0, db_1.getPool)().query(sql_1.UPSERT_ACTION, ruleParams(input));
69
75
  return rows[0];
70
76
  }
@@ -83,3 +89,19 @@ async function seedScanRule(input) {
83
89
  const { rowCount } = await (0, db_1.getPool)().query(sql_1.SEED_ACTION, ruleParams(input));
84
90
  return (rowCount ?? 0) > 0;
85
91
  }
92
+ /**
93
+ * Apply a scheme declaration at startup (code is source of truth). Same
94
+ * validation as the upsert; the IS DISTINCT FROM guard makes an unchanged
95
+ * declaration a zero-row no-op.
96
+ */
97
+ async function applyScanScheme(input) {
98
+ (0, validate_1.assertValidScheme)(input);
99
+ const { rowCount } = await (0, db_1.getPool)().query(sql_1.APPLY_SCHEME, schemeParams(input));
100
+ return (rowCount ?? 0) > 0 ? 'applied' : 'unchanged';
101
+ }
102
+ /** Apply a rule declaration at startup (code is source of truth). */
103
+ async function applyScanRule(input) {
104
+ await assertValidRuleInput(input);
105
+ const { rowCount } = await (0, db_1.getPool)().query(sql_1.APPLY_ACTION, ruleParams(input));
106
+ return (rowCount ?? 0) > 0 ? 'applied' : 'unchanged';
107
+ }
@@ -472,6 +472,58 @@ async function startWorkers(startConfig, workers, builtinMcpServerFactories) {
472
472
  (0, apply_report_1.logSurfaceReport)('agents', agentReport);
473
473
  }
474
474
  }
475
+ // Register declared scan schemes (with their rules) — ownership per
476
+ // configSource / per-entry reset. A scheme and its rules are one ownership
477
+ // unit; the scheme applies first so rule validation sees its kind.
478
+ if (startConfig.scanSchemes?.length) {
479
+ const { seedScanScheme, seedScanRule, applyScanScheme, applyScanRule, listScanSchemes, listScanRules, } = await Promise.resolve().then(() => __importStar(require('../services/scan-code')));
480
+ const scanReport = (0, apply_report_1.newSurfaceReport)();
481
+ for (const scheme of startConfig.scanSchemes) {
482
+ const codeOwned = (0, apply_report_1.ownedByCode)(scheme.reset, configSource);
483
+ const { rules, reset: _reset, ...schemeInput } = scheme;
484
+ try {
485
+ if (codeOwned) {
486
+ let outcome = await applyScanScheme(schemeInput);
487
+ for (const rule of rules ?? []) {
488
+ const ruleOutcome = await applyScanRule({ scheme_version: scheme.version, ...rule });
489
+ if (ruleOutcome === 'applied')
490
+ outcome = 'applied';
491
+ }
492
+ (0, apply_report_1.recordOutcome)(scanReport, `scheme ${scheme.version}`, outcome);
493
+ if (outcome === 'applied')
494
+ logger_1.loggerRegistry.info(`[long-tail] scan scheme applied: ${scheme.version}`);
495
+ // Undeclared rules on a code-owned scheme are reported, never deleted.
496
+ const declaredCategories = new Set((rules ?? []).map((r) => r.category));
497
+ const existingRules = await listScanRules(scheme.version);
498
+ scanReport.orphans.push(...existingRules
499
+ .filter((r) => !declaredCategories.has(r.category))
500
+ .map((r) => `${scheme.version}/${r.category}`));
501
+ }
502
+ else {
503
+ const inserted = await seedScanScheme(schemeInput);
504
+ for (const rule of rules ?? []) {
505
+ await seedScanRule({ scheme_version: scheme.version, ...rule });
506
+ }
507
+ (0, apply_report_1.recordOutcome)(scanReport, `scheme ${scheme.version}`, 'db-owned');
508
+ if (inserted)
509
+ logger_1.loggerRegistry.info(`[long-tail] scan scheme seeded: ${scheme.version}`);
510
+ }
511
+ }
512
+ catch (err) {
513
+ logger_1.loggerRegistry.warn(`[long-tail] scan scheme seed failed for ${scheme.version}: ${err.message}`);
514
+ }
515
+ }
516
+ if (codeOwnedBoot && !startConfig.examples) {
517
+ // Schemes in the DB not declared here (demo seeds own theirs under examples).
518
+ const declaredVersions = new Set(startConfig.scanSchemes.map((s) => s.version));
519
+ scanReport.orphans.push(...(await listScanSchemes())
520
+ .map((s) => s.version)
521
+ .filter((v) => !declaredVersions.has(v))
522
+ .map(String));
523
+ }
524
+ if (codeOwnedBoot)
525
+ (0, apply_report_1.logSurfaceReport)('scan-schemes', scanReport);
526
+ }
475
527
  // Register the in-process callback adapter for agent event triggers.
476
528
  // Reuse existing instance if already registered (e.g., from SDK createClient).
477
529
  const { CallbackEventAdapter } = await Promise.resolve().then(() => __importStar(require('../lib/events/callback')));
@@ -1,4 +1,4 @@
1
- import type { Request } from 'express';
1
+ import type { Request, Response } from 'express';
2
2
  /**
3
3
  * The identity payload extracted from an authenticated request.
4
4
  * All auth adapters must return this shape.
@@ -91,8 +91,17 @@ export interface LTSSOConfig {
91
91
  /** Extract user identity from the host's authenticated request.
92
92
  * Return `null` if the request is not authenticated by the host.
93
93
  * The `req` object carries cookies, headers, and any properties
94
- * attached by upstream middleware (e.g., `req.user`). */
95
- resolve: (req: Request) => Promise<SSOIdentity | null> | (SSOIdentity | null);
94
+ * attached by upstream middleware (e.g., `req.user`).
95
+ *
96
+ * `res` is defined only during the explicit SSO exchange
97
+ * (`POST /api/auth/sso` — the login exchange and every keepalive beat).
98
+ * Hosts that own their session cookie set/refresh it here: headers only —
99
+ * never write the status or body; the exchange route writes the response
100
+ * after `resolve` returns. `res` is deliberately absent on the per-request
101
+ * auth fallback, so ambient cookie-bearing API traffic (prefetches,
102
+ * background fetches) never slides the host session — only the login
103
+ * exchange and the visibility/idle-gated keepalive beat do. */
104
+ resolve: (req: Request, res?: Response) => Promise<SSOIdentity | null> | (SSOIdentity | null);
96
105
  /** Map host role names to LT role names.
97
106
  * Key = host role, value = LT role name.
98
107
  * A configured map is the COMPLETE contract: an unmapped host role means
@@ -114,10 +123,15 @@ export interface LTSSOConfig {
114
123
  /**
115
124
  * Session keepalive: while the dashboard is open and visible, the SPA
116
125
  * re-runs the credentialed SSO exchange (`POST /api/auth/sso`, host cookies
117
- * included) every `keepaliveSeconds`, keeping a short-lived sliding host
118
- * session warm exactly the way host-app navigation does. Each beat re-runs
119
- * `resolve`, so a host that revokes access cuts the dashboard off at the
120
- * next beat. 0/omitted = no keepalive. Values under 15s are clamped to 15s.
126
+ * included) every `keepaliveSeconds`. Each beat re-runs `resolve`, so a
127
+ * host that revokes access cuts the dashboard off at the next beat.
128
+ * 0/omitted = no keepalive. Values under 15s are clamped to 15s.
129
+ *
130
+ * Hosts whose middleware slides the session on any request get the sliding
131
+ * effect from the beat automatically. Hosts that mint the session cookie in
132
+ * their own login flow re-mint it inside `resolve` via the `res` parameter.
133
+ * Either way, pair a sliding TTL with an absolute session max-age — a
134
+ * sliding-only session is otherwise extendable indefinitely.
121
135
  */
122
136
  keepaliveSeconds?: number;
123
137
  /**
@@ -17,7 +17,7 @@ export type { LTLoggerAdapter, } from './logger';
17
17
  export type { LTMaintenanceRule, LTMaintenanceConfig, } from './maintenance';
18
18
  export type { LTExportField, LTExportOptions, LTTimelineEntry, LTTransitionEntry, LTWorkflowExport, } from './export';
19
19
  export type { WorkflowExecution, WorkflowExecutionEvent, WorkflowExecutionStatus, WorkflowExecutionSummary, WorkflowEventType, WorkflowEventCategory, WorkflowEventAttributes, ExecutionExportOptions, ExportMode, ActivityDetail, JobExport, } from './export';
20
- export type { LTStartConfig, LTInstance, LTWorkerConfig, LTMcpServerConfig, LTAgentConfig, LTTopicConfig, LTRoleConfig, } from './startup';
20
+ export type { LTStartConfig, LTInstance, LTWorkerConfig, LTMcpServerConfig, LTAgentConfig, LTTopicConfig, LTRoleConfig, LTScanSchemeConfig, LTScanRuleConfig, } from './startup';
21
21
  export type { LTMcpTransportType, LTMcpServerRecord, LTMcpServerStatus, LTMcpToolManifest, LTMcpAdapter, } from './mcp';
22
22
  export type { ResolutionContext, ResolutionDirective, LTEscalationStrategy, } from './escalation-strategy';
23
23
  export type { WorkflowCandidate, } from './discovery';
@@ -1,5 +1,6 @@
1
1
  import type { LoggerOptions } from 'pino';
2
2
  import type { LTAuthAdapter, LTSSOConfig } from './auth';
3
+ import type { ScanEncoding, ScanSchemeKind, ScanStep, ScanRuleFallback } from './scan-code';
3
4
  import type { LTOAuthStartConfig } from './oauth';
4
5
  import type { LTTelemetryAdapter } from './telemetry';
5
6
  import type { LTEventAdapter } from './events';
@@ -211,6 +212,50 @@ export interface LTRoleConfig {
211
212
  */
212
213
  reset?: boolean;
213
214
  }
215
+ /**
216
+ * One rule (category) of a declared scan scheme. Categories are single
217
+ * digits; a rule needs at least one step or a fallback.
218
+ */
219
+ export interface LTScanRuleConfig {
220
+ /** Single digit '0'-'9'. */
221
+ category: string;
222
+ name: string;
223
+ steps: ScanStep[];
224
+ fallback?: ScanRuleFallback;
225
+ /** Shown when the scan arrives with no primed work surface. */
226
+ notPrimed?: ScanRuleFallback;
227
+ enabled?: boolean;
228
+ }
229
+ /**
230
+ * Declarative scan scheme registration — the scheme and its rules are one
231
+ * ownership unit, owned per `configSource` (overridable per entry with
232
+ * `reset`). Code-owned schemes are compared and applied on every boot;
233
+ * db-owned schemes are insert-if-absent.
234
+ */
235
+ export interface LTScanSchemeConfig {
236
+ /** Scheme version prefix, 10-99. The scheme's identity. */
237
+ version: number;
238
+ name: string;
239
+ description?: string | null;
240
+ /** The escalation metadata facet a scanned target resolves against. */
241
+ target_facet: string;
242
+ encoding?: ScanEncoding;
243
+ delimiter?: string;
244
+ target_length?: number | null;
245
+ /** 'action' (default) or 'identity' (badge grants). */
246
+ kind?: ScanSchemeKind;
247
+ grant_ttl_seconds?: number | null;
248
+ grant_max_uses?: number;
249
+ enabled?: boolean;
250
+ /** The scheme's rules, one per category. */
251
+ rules?: LTScanRuleConfig[];
252
+ /**
253
+ * Per-entry ownership override for the scheme AND its rules. `true` →
254
+ * compared and applied on every boot; `false` → the DB owns the rows after
255
+ * first insert. Omitted → follows `configSource`.
256
+ */
257
+ reset?: boolean;
258
+ }
214
259
  /**
215
260
  * Declarative graph (YAML/DAG) workflow registered at startup — the graph-form
216
261
  * peer of a `workers` entry (which registers a procedural workflow). Hand-author
@@ -469,6 +514,13 @@ export interface LTStartConfig {
469
514
  * escalation targets, and ops dials, owned per `configSource`.
470
515
  */
471
516
  roles?: LTRoleConfig[];
517
+ /**
518
+ * Declarative scan schemes (with their rules), owned per `configSource` /
519
+ * per-entry `reset`. Declaring schemes states intent — the pass runs
520
+ * whether or not the dashboard scan affordances (`features.scanCodes`) are
521
+ * enabled.
522
+ */
523
+ scanSchemes?: LTScanSchemeConfig[];
472
524
  /** Declarative topic catalog entries, owned per `configSource` / per-entry `reset`. */
473
525
  topics?: LTTopicConfig[];
474
526
  /** Declarative agent configurations, owned per `configSource` / per-entry `reset`. */
package/docs/auth.md CHANGED
@@ -231,9 +231,34 @@ Response 200:
231
231
  }
232
232
  ```
233
233
 
234
+ ### Host Session on the Exchange
235
+
236
+ The exchange is the session boundary between the host and Long Tail, and `resolve` receives the response handle there: `resolve(req, res)`. Hosts that own their session cookie set or refresh it inside `resolve` — headers only; Long Tail writes the response status and body after `resolve` returns.
237
+
238
+ ```typescript
239
+ auth: {
240
+ sso: {
241
+ keepaliveSeconds: 300,
242
+ resolve: (req, res) => {
243
+ const session = validateHostCookie(req);
244
+ if (!session) return null;
245
+ // Slide the host session on every exchange — login and each keepalive beat.
246
+ res?.setHeader('Set-Cookie', mintHostCookie(session));
247
+ return { externalId: session.userId, roles: session.roles };
248
+ },
249
+ },
250
+ },
251
+ ```
252
+
253
+ With `keepaliveSeconds` set, the dashboard re-runs the exchange on a visibility- and idle-gated interval, so an active operator's host session slides exactly as it would under host-app navigation — hosts whose middleware slides on any request get this automatically, and cookie-minting hosts re-mint via `res`. Pair a sliding TTL with an absolute session max-age: a sliding-only session is otherwise extendable indefinitely.
254
+
255
+ A resolve that slides the session makes this endpoint a session-refresh surface — give it the same CSRF posture as your own refresh route. `SameSite=Lax` or `Strict` session cookies cover it (cross-site POSTs carry no cookie); a host running `SameSite=None` should verify `Origin` or `Sec-Fetch-Site` inside `resolve` before sliding.
256
+
257
+ `res` is present only on the explicit exchange (`POST /api/auth/sso`). The per-request fallback below calls `resolve(req)` without it, so ambient cookie-bearing API traffic — prefetches, background fetches — never slides the host session.
258
+
234
259
  ### requireAuth Fallback
235
260
 
236
- When SSO is configured and a request arrives without a Bearer token, `requireAuth` calls `sso.resolve(req)` as a fallback. This allows direct API calls from the host backend (which forward cookies but not Bearer tokens) to authenticate without an explicit exchange. The dashboard always uses Bearer after the initial exchange.
261
+ When SSO is configured and a request arrives without a Bearer token, `requireAuth` calls `sso.resolve(req)` as a fallback. This allows direct API calls from the host backend (which forward cookies but not Bearer tokens) to authenticate without an explicit exchange. The dashboard always uses Bearer after the initial exchange. The fallback never passes `res` — session sliding belongs exclusively to the exchange.
237
262
 
238
263
  ### Role Mapping
239
264
 
@@ -63,6 +63,7 @@ The domain dictionary's override lives beside its path:
63
63
  | MCP servers | `mcp.serverFactories[].config` | description, tags, category, compile hints, credential providers | tool manifest, connection status |
64
64
  | Agents | `agents[]` | description, status, goals, rules, domain, schedules | capabilities, metadata, run history |
65
65
  | Agent subscriptions | `agents[].subscriptions` | reaction fields on (agent, topic) | `enabled` — the admin kill-switch |
66
+ | Scan schemes | `scanSchemes[]` | whole scheme + its rules (one ownership unit; `enabled` is declarative here) | — |
66
67
  | Domain dictionary | `mcp.domainDictionaryPath` | whole document (version bumps once per change) | — |
67
68
  | Graph workflows | `graphWorkflows[]` | always compare-and-apply: description/schema sync, redeploy on YAML version bump | run history |
68
69
 
@@ -105,6 +106,32 @@ render keep working while new escalations pick up the new shape. Version
105
106
  lineage only grows — an apply never rewinds or rewrites an existing version.
106
107
  `list_schema` versions independently, exactly as it does from the dashboard.
107
108
 
109
+ ## Scan schemes: scheme + rules as one unit
110
+
111
+ A scan scheme and its rules declare together — a rule cannot exist without its
112
+ scheme, so the pair is the ownership unit and one `reset` covers both:
113
+
114
+ ```typescript
115
+ scanSchemes: [
116
+ {
117
+ version: 12,
118
+ name: 'Serial locate',
119
+ target_facet: 'serialNumber',
120
+ encoding: 'fixed',
121
+ target_length: 8,
122
+ rules: [
123
+ { category: '1', name: 'Locate', steps: [{ verb: 'show-detail' }] },
124
+ ],
125
+ },
126
+ ],
127
+ ```
128
+
129
+ The scheme applies before its rules so rule validation sees the scheme's kind.
130
+ Rules present in the database but absent from a code-owned scheme's
131
+ declaration are reported as `version/category` orphans. The pass runs whether
132
+ or not the dashboard scan affordances (`features.scanCodes`) are enabled —
133
+ declaring schemes states intent.
134
+
108
135
  ## The boot report
109
136
 
110
137
  Each surface logs one line summarizing its pass:
@@ -360,6 +360,30 @@ The same CRUD rides `PUT/GET/DELETE /api/scan-codes/schemes/:version[/actions/:c
360
360
  rules fail the write with the exact problem — an `escalate` step names its
361
361
  `targetRole`, a `resolve` step carries its payload.
362
362
 
363
+ ## Declaring schemes in code
364
+
365
+ Schemes and their rules declare on `LTStartConfig.scanSchemes` and follow the
366
+ [code-owned configuration](code-owned-configuration.md) contract: under
367
+ `configSource: 'code'` a changed scheme or rule in code is live after the next
368
+ boot on every environment; under `'db'` (the default) the declaration seeds
369
+ once and the admin surfaces own it afterward. Per-entry `reset` overrides the
370
+ dial in either direction, covering the scheme and its rules together.
371
+
372
+ ```typescript
373
+ scanSchemes: [
374
+ {
375
+ version: 12,
376
+ name: 'Serial locate',
377
+ target_facet: 'serialNumber',
378
+ encoding: 'fixed',
379
+ target_length: 8,
380
+ rules: [
381
+ { category: '1', name: 'Locate', steps: [{ verb: 'show-detail' }] },
382
+ ],
383
+ },
384
+ ],
385
+ ```
386
+
363
387
  ## The printer demo
364
388
 
365
389
  The example seed (`examples/seed-scan-codes.ts`) configures scheme 10 over
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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",