@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.
- package/build/api/auth-sso.d.ts +6 -3
- package/build/api/auth-sso.js +6 -3
- package/build/modules/auth.js +9 -3
- package/build/routes/auth-sso.js +6 -1
- package/build/services/scan-code/index.d.ts +1 -1
- package/build/services/scan-code/index.js +3 -1
- package/build/services/scan-code/sql.d.ts +7 -0
- package/build/services/scan-code/sql.js +55 -1
- package/build/services/scan-code/write.d.ts +8 -0
- package/build/services/scan-code/write.js +23 -1
- package/build/start/workers.js +52 -0
- package/build/types/auth.d.ts +21 -7
- package/build/types/index.d.ts +1 -1
- package/build/types/startup.d.ts +52 -0
- package/docs/auth.md +26 -1
- package/docs/code-owned-configuration.md +27 -0
- package/docs/scan-codes.md +24 -0
- package/package.json +1 -1
package/build/api/auth-sso.d.ts
CHANGED
|
@@ -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>;
|
package/build/api/auth-sso.js
CHANGED
|
@@ -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
|
}
|
package/build/modules/auth.js
CHANGED
|
@@ -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)
|
package/build/routes/auth-sso.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/build/start/workers.js
CHANGED
|
@@ -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')));
|
package/build/types/auth.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
/**
|
package/build/types/index.d.ts
CHANGED
|
@@ -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';
|
package/build/types/startup.d.ts
CHANGED
|
@@ -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:
|
package/docs/scan-codes.md
CHANGED
|
@@ -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