@sema-agent/server 7.50.0 → 7.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/device-lane.d.ts +79 -0
- package/dist/boot/device-lane.js +63 -0
- package/dist/boot/execution-env.d.ts +48 -2
- package/dist/boot/execution-env.js +57 -5
- package/dist/boot/resolve-spec.d.ts +3 -0
- package/dist/boot/resolve-spec.js +12 -9
- package/dist/boot/session-faces.d.ts +5 -0
- package/dist/boot/session-faces.js +4 -1
- package/dist/boot/shutdown.d.ts +8 -0
- package/dist/boot/shutdown.js +3 -1
- package/dist/boot/stores.js +9 -0
- package/dist/config-center/types.d.ts +10 -3
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +18 -0
- package/dist/config-types.d.ts +12 -0
- package/dist/config.js +46 -9
- package/dist/device-enrollment.d.ts +125 -0
- package/dist/device-enrollment.js +156 -0
- package/dist/device-store.d.ts +385 -0
- package/dist/device-store.js +407 -0
- package/dist/device-ws-hub.d.ts +182 -0
- package/dist/device-ws-hub.js +1012 -0
- package/dist/device-ws-protocol.d.ts +429 -0
- package/dist/device-ws-protocol.js +464 -0
- package/dist/env-facts.d.ts +4 -1
- package/dist/env-facts.js +1 -0
- package/dist/execution-lane-caps.d.ts +152 -0
- package/dist/execution-lane-caps.js +166 -0
- package/dist/http/routes/capabilities.js +7 -2
- package/dist/http/server.d.ts +14 -0
- package/dist/http/server.js +4 -1
- package/dist/leader/wire.js +4 -2
- package/dist/main.js +10 -3
- package/dist/orchestration/hardened-vm-runner.d.ts +7 -0
- package/dist/orchestration/hardened-vm-runner.js +11 -1
- package/dist/orchestration/hardened-vm-worker-runner.js +2 -2
- package/dist/plugins/device-store-sql.d.ts +130 -0
- package/dist/plugins/device-store-sql.js +574 -0
- package/dist/plugins/remote-env-device.d.ts +271 -0
- package/dist/plugins/remote-env-device.js +727 -0
- package/dist/plugins/remote-scratchpad.js +1 -1
- package/dist/plugins/store-backend.d.ts +9 -0
- package/dist/plugins/store-backend.js +3 -0
- package/dist/task-cwd.d.ts +25 -0
- package/dist/task-cwd.js +3 -0
- package/dist/task-settings.js +3 -1
- package/package.json +2 -2
package/dist/config.js
CHANGED
|
@@ -11,6 +11,7 @@ import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
|
|
|
11
11
|
import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
|
|
12
12
|
import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
|
|
13
13
|
import { normalizeSshFingerprint } from "./ssh-host-key.js";
|
|
14
|
+
import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
|
|
14
15
|
export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
|
|
15
16
|
function csv(name) {
|
|
16
17
|
return (process.env[name] ?? "")
|
|
@@ -736,11 +737,17 @@ function parseDegradeOn(words) {
|
|
|
736
737
|
}
|
|
737
738
|
return words;
|
|
738
739
|
}
|
|
740
|
+
const PRESENTED_NAME_MAX = 160;
|
|
741
|
+
function presentToolName(name) {
|
|
742
|
+
const clean = name.replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
743
|
+
return clean.length <= PRESENTED_NAME_MAX ? clean : `${clean.slice(0, PRESENTED_NAME_MAX)}…(${name.length} chars)`;
|
|
744
|
+
}
|
|
739
745
|
function ccRuleFormGuidance(name) {
|
|
740
746
|
if (!name.includes("(") && !name.includes(")"))
|
|
741
747
|
return undefined;
|
|
742
|
-
|
|
743
|
-
|
|
748
|
+
const shown = presentToolName(name);
|
|
749
|
+
return (`CC-form rule specifier — this list matches the WHOLE tool name verbatim, so it can only ever match a tool literally named "${shown}". ` +
|
|
750
|
+
`Drop the parentheses to gate the tool itself ("${presentToolName(name.split("(")[0] ?? name)}"); ` +
|
|
744
751
|
`to decide per COMMAND use \`runtime.commandPolicy\` ({command, decision} — bare argv[0] names); ` +
|
|
745
752
|
`to put every shell call behind an approval use MANUAL_MODE_SHELL_GATE=always`);
|
|
746
753
|
}
|
|
@@ -783,7 +790,7 @@ export function findUnmatchableToolNames(names) {
|
|
|
783
790
|
const ns = protocolOf(name);
|
|
784
791
|
if (ns === undefined) {
|
|
785
792
|
if (name.includes("__"))
|
|
786
|
-
out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${name}`).join(" or ")}` });
|
|
793
|
+
out.push({ name, guidance: `pre-prefix short form — the live name carries a protocol prefix: ${PROTOCOL_TABLE.map((e) => `${e.prefix}${presentToolName(name)}`).join(" or ")}` });
|
|
787
794
|
continue;
|
|
788
795
|
}
|
|
789
796
|
if (ns.parse(name) === undefined) {
|
|
@@ -794,7 +801,7 @@ export function findUnmatchableToolNames(names) {
|
|
|
794
801
|
}
|
|
795
802
|
export function formatUnmatchableToolNames(source, bad) {
|
|
796
803
|
return (`${source} names tool(s) that can never match a live tool (these lists are RAW whole-name comparisons — core 5.0.0 RB-476 removed the alias/auto-prefix folds): ` +
|
|
797
|
-
bad.map((b) => `${b.name} → ${b.guidance}`).join("; "));
|
|
804
|
+
bad.map((b) => `${presentToolName(b.name)} → ${b.guidance}`).join("; ")).replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
798
805
|
}
|
|
799
806
|
function streamApprovalConfig() {
|
|
800
807
|
const cfg = {
|
|
@@ -1277,10 +1284,11 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1277
1284
|
adb: ["ADB_SERIAL"],
|
|
1278
1285
|
host: [],
|
|
1279
1286
|
"local-docker": ["DOCKER_IMAGE"],
|
|
1287
|
+
device: [],
|
|
1280
1288
|
};
|
|
1281
|
-
const required = Object.
|
|
1289
|
+
const required = Object.entries(REQUIRED_ENVS_BY_LANE).find(([word]) => word === lane)?.[1];
|
|
1282
1290
|
if (required === undefined) {
|
|
1283
|
-
throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid:
|
|
1291
|
+
throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid: ${Object.keys(REQUIRED_ENVS_BY_LANE).join(" | ")}. ` +
|
|
1284
1292
|
`An unrecognized value used to fall through to the host/in-process lane silently; refusing to start instead (#157).`);
|
|
1285
1293
|
}
|
|
1286
1294
|
const missing = required.filter((name) => !process.env[name]);
|
|
@@ -1385,9 +1393,19 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1385
1393
|
...(process.env.DOCKER_SANDBOX_ENV ? { env: resolveEnvForward(process.env.DOCKER_SANDBOX_ENV) } : {}),
|
|
1386
1394
|
}
|
|
1387
1395
|
:
|
|
1388
|
-
process.env.
|
|
1389
|
-
? {
|
|
1390
|
-
|
|
1396
|
+
process.env.REMOTE_EXEC === "device"
|
|
1397
|
+
? {
|
|
1398
|
+
provider: "device",
|
|
1399
|
+
...((n) => (n !== undefined ? { deliveryTimeoutMs: n } : {}))(optNumEnvStrict("DEVICE_DELIVERY_TIMEOUT_MS")),
|
|
1400
|
+
...((n) => (n !== undefined ? { reconnectGraceMs: n } : {}))(optNumEnvStrict("DEVICE_RECONNECT_GRACE_MS")),
|
|
1401
|
+
...((n) => (n !== undefined ? { execTimeoutMs: n } : {}))(optNumEnvStrict("DEVICE_EXEC_TIMEOUT_MS")),
|
|
1402
|
+
...((n) => (n !== undefined ? { maxInflightPerDevice: n } : {}))(optNumEnvStrict("DEVICE_MAX_INFLIGHT_PER_DEVICE")),
|
|
1403
|
+
...(process.env.DEVICE_SUPERSEDE ? { supersede: enumEnv("DEVICE_SUPERSEDE", "stale_only", ["stale_only", "deny"]) } : {}),
|
|
1404
|
+
}
|
|
1405
|
+
:
|
|
1406
|
+
process.env.CONFIG_PROVIDER === "local"
|
|
1407
|
+
? { provider: "host" }
|
|
1408
|
+
: undefined,
|
|
1391
1409
|
worktreeIsolation: boolEnv("WORKTREE_ISOLATION_ENABLED", false) && process.env.WORKTREE_REPO_ROOT
|
|
1392
1410
|
? {
|
|
1393
1411
|
repoRoot: process.env.WORKTREE_REPO_ROOT,
|
|
@@ -1803,6 +1821,25 @@ export function loadConfig() {
|
|
|
1803
1821
|
"push. Point the service at a durable store (DB_BACKEND=mysql|pg), or turn the leader off (unset LEADER_ENABLED) " +
|
|
1804
1822
|
"(#193 车4 件2 / staleness ledger P1-9+P1-10 boot invariant)");
|
|
1805
1823
|
}
|
|
1824
|
+
{
|
|
1825
|
+
const deviceLane = orchestration.remoteExec?.provider === "device";
|
|
1826
|
+
const deviceSqlStore = (store.dbBackend === "mysql" && store.tidb !== undefined) || (store.dbBackend === "pg" && store.pg !== undefined);
|
|
1827
|
+
if (deviceLane && !deviceSqlStore) {
|
|
1828
|
+
throw new Error(`REMOTE_EXEC=device with DB_BACKEND=${store.dbBackend} and no SQL connection coordinates built is refused ` +
|
|
1829
|
+
"[device-durable-store]: the device registry, the session↔device binding and the device audit trail are all " +
|
|
1830
|
+
"SQL-backed — without a real SQL store the binding lives in this process only, so a restart forgets WHICH device " +
|
|
1831
|
+
"a parked run must resume onto and a second replica cannot see it at all. Note DB_BACKEND alone is not enough: " +
|
|
1832
|
+
"SESSION_BACKEND=memory (or auto with no MYSQL_HOST/PG_HOST) builds no coordinates at all. Configure a durable " +
|
|
1833
|
+
"store (DB_BACKEND=mysql|pg + its *_HOST/*_USER/*_DATABASE), or unset REMOTE_EXEC " +
|
|
1834
|
+
"(device-executor-lane-v2 §4.7 boot invariant)");
|
|
1835
|
+
}
|
|
1836
|
+
if (deviceLane && !dgDurable) {
|
|
1837
|
+
throw new Error("REMOTE_EXEC=device without DURABLE_APPROVAL=true is refused [device-durable-approval]: a personal device goes " +
|
|
1838
|
+
"offline (lid closed, network drop) at arbitrary points, so an in-flight approval that outlives the window MUST " +
|
|
1839
|
+
"have a park exit — without the durable approval store there is none and the run is stuck with nowhere to persist. " +
|
|
1840
|
+
"Set DURABLE_APPROVAL=true, or unset REMOTE_EXEC (device-executor-lane-v2 §4.7 boot invariant)");
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1806
1843
|
EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
|
|
1807
1844
|
return attachConfigGroups({
|
|
1808
1845
|
...store,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device lane 准入链(design/device-executor-lane-v2 §4.3.2 提交期准入 + §7-T5 enrollment 五件 +
|
|
3
|
+
* §7-T6 反枚举)—— **纯函数层**:没有 HTTP、没有 WS、没有 core 装配,只有「店 + 判据 → typed 判决」。
|
|
4
|
+
* 端点(`/v1/devices/*`、`GET /v1/device/ws`)由车A-3/车C 接;它们**必须**经由这里的判决,不得自建判据。
|
|
5
|
+
*
|
|
6
|
+
* ── 五件(§7-T5,逐条落在哪儿)────────────────────────────────────────────────────────────────────
|
|
7
|
+
* ① TTL —— `DeviceStore.consumeEnrollToken` 的 CAS 谓词 `expires_at > NOW(3)`(**DB 时钟**,§4.6-F18)。
|
|
8
|
+
* ② 绑定 principal —— 同一条 CAS 谓词里 `target_tenant/target_subject` 逐字节等值;不匹配 = 拒。
|
|
9
|
+
* ③ 消费一次性 CAS —— `consumed_at IS NULL` 谓词 + `rev=rev+1`;**affected=0 时只有同 pubkey 放行**。
|
|
10
|
+
* ④ 重用审计 —— 异 pubkey ⇒ `device_enroll_token_reuse` 审计行(告警级);principal 不符 ⇒
|
|
11
|
+
* `device_identity_mismatch` 审计行。两条都在**拒绝之前**写,失败即整条请求失败(fail-closed:
|
|
12
|
+
* 审计写不进去 ≠ 放行)。
|
|
13
|
+
* ⑤ 限速 —— {@link DeviceEnrollRateLimiter},注入式。缺席 = **不限速**,所以端点车必须传一个
|
|
14
|
+
* (§7-T5 ⑤ 双限速);本层不替它兜底,也不假装限了。
|
|
15
|
+
*
|
|
16
|
+
* ── 反枚举(§4.8 + memory `anti-enum-404-ambiguity`)──────────────────────────────────────────────
|
|
17
|
+
* enrollment 的**全部**拒绝理由(token 不存在 / 过期 / principal 不符 / 被别的 pubkey 用过)在 wire
|
|
18
|
+
* 上是**同一个** `device.enrollment_invalid`;设备读面的「不存在」与「不是你的」是同一个
|
|
19
|
+
* `not_found.device`。细分只进审计。
|
|
20
|
+
*
|
|
21
|
+
* ── 吊销在途语义(§8-R4)──────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* `revoked` 是终态:①绑定的 session 后续提交 = `device.revoked`(admitSession 步 2,**先于** presence
|
|
23
|
+
* 判定);②吊销同事务把连接租约作废并把 `conn_generation` 推进一格 ⇒ 在途连接的每一次带世代谓词的写
|
|
24
|
+
* 都会影响 0 行,设备侧因此立刻失权(§4.6 步 2);③重新 enrollment = 新 deviceId,不复活旧行。
|
|
25
|
+
*/
|
|
26
|
+
import { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
|
|
27
|
+
/** typed 拒绝 —— 端点层只负责把它翻成 HTTP(status 已在体内,不许调用方另算)。 */
|
|
28
|
+
export interface DeviceRejection {
|
|
29
|
+
code: DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
|
|
30
|
+
status: number;
|
|
31
|
+
message: string;
|
|
32
|
+
/** 限流专用:`Retry-After` 的秒值(附录 A 的 429 族纪律)。 */
|
|
33
|
+
retryAfterSec?: number;
|
|
34
|
+
}
|
|
35
|
+
export type DeviceOutcome<T> = {
|
|
36
|
+
ok: true;
|
|
37
|
+
value: T;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reject: DeviceRejection;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 限速座(§7-T5 ⑤)。**fail-closed**:`check` 抛错 = 拒绝(不是放行)—— 限速器不可用时放行,正是
|
|
44
|
+
* token 撞库最想要的那一刻。
|
|
45
|
+
*/
|
|
46
|
+
export interface DeviceEnrollRateLimiter {
|
|
47
|
+
check(key: string): Promise<{
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
retryAfterSec?: number;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
export interface EnrollDeviceInput {
|
|
53
|
+
/** 明文 token(**只**在这里出现,落库前立刻摘要)。 */
|
|
54
|
+
token: string;
|
|
55
|
+
/** 请求方**验证过**的复合身份(SSO/凭据链产出,恒不取自可伪造头)。 */
|
|
56
|
+
requester: DeviceOwner;
|
|
57
|
+
/** 🔴 `deviceId` 与 `owner` **不在**这里:前者恒由 server 铸(调用方选 id = 让请求方挑自己的设备身份,
|
|
58
|
+
* 与 §6「server 铸 dev_<ulid>」相悖),后者恒取自**验证过的** requester —— 两个字段都不接受请求体供给。
|
|
59
|
+
* 测试要确定性 id ⇒ 注入 {@link DeviceEnrollmentDeps.mintId},不是从入参开一个口子。 */
|
|
60
|
+
device: Omit<NewDeviceInput, "deviceId" | "owner">;
|
|
61
|
+
}
|
|
62
|
+
export interface EnrollDeviceValue {
|
|
63
|
+
device: DeviceRow;
|
|
64
|
+
/** `true` = 幂等重放(响应丢失重试),`false` = 本次真注册。wire 上两者同为 200 + 同 deviceId。 */
|
|
65
|
+
replayed: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface AdmitSessionInput {
|
|
68
|
+
rootSessionId: string;
|
|
69
|
+
requester: DeviceOwner;
|
|
70
|
+
/** 提交体携带的 deviceId(§4.3.2:仅根任务携;缺席 = 沿用绑定)。 */
|
|
71
|
+
requestedDeviceId?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface AdmitSessionValue {
|
|
74
|
+
deviceId: string;
|
|
75
|
+
device: DeviceRow;
|
|
76
|
+
/**
|
|
77
|
+
* 判决**当刻**的连接世代(§4.6 fencing token)。
|
|
78
|
+
*
|
|
79
|
+
* 🔴 **准入判决是一个快照,不是一把锁**(codex 对抗轮 F4,判真但归属下一车):`admitSession` 返回后、
|
|
80
|
+
* 调用方真正下发指令前,一次 `revokeDevice` 可以提交 —— 它会作废租约并把 `conn_generation` 推进一格,
|
|
81
|
+
* 但它**追不回**已经基于旧判决发出去的指令。这不是本层能关掉的窗(§6 注③ 刻意不落指令表,所以持久层
|
|
82
|
+
* 没有「派发」这个写点可以带谓词);设计给的答案是**在下发边界重新断言世代**(§4.6 步 2/3:每一次带
|
|
83
|
+
* `conn_generation = ?` 谓词的写影响 0 行 = 本连接已失权 ⇒ 停发并断连)。
|
|
84
|
+
* 本字段就是那个断言要用的值 —— 车A-3 的 WS 汇聚端**必须**拿它去 fence,拿不到就不要下发。
|
|
85
|
+
* 残余诚实:窗口上界 = 心跳周期(吊销后设备侧最迟一个心跳失败即失权),§8-R4 对已发指令的账面处置是
|
|
86
|
+
* 「在途结果帧丢弃 + 审计,对应调用按 outcome-unknown 收尾」。
|
|
87
|
+
*/
|
|
88
|
+
connGeneration: number;
|
|
89
|
+
}
|
|
90
|
+
export interface DeviceEnrollment {
|
|
91
|
+
enroll(input: EnrollDeviceInput): Promise<DeviceOutcome<EnrollDeviceValue>>;
|
|
92
|
+
bindPlacement(input: {
|
|
93
|
+
rootSessionId: string;
|
|
94
|
+
deviceId: string;
|
|
95
|
+
requester: DeviceOwner;
|
|
96
|
+
}): Promise<DeviceOutcome<DeviceSessionRow>>;
|
|
97
|
+
admitSession(input: AdmitSessionInput): Promise<DeviceOutcome<AdmitSessionValue>>;
|
|
98
|
+
revoke(input: {
|
|
99
|
+
deviceId: string;
|
|
100
|
+
requester: DeviceOwner;
|
|
101
|
+
revokedBy: string;
|
|
102
|
+
reason?: string | null;
|
|
103
|
+
}): Promise<DeviceOutcome<DeviceRow>>;
|
|
104
|
+
}
|
|
105
|
+
export interface DeviceEnrollmentDeps {
|
|
106
|
+
store: DeviceStore;
|
|
107
|
+
/** 缺席 = 不限速(端点车必须传;见文件头注 ⑤)。 */
|
|
108
|
+
rateLimiter?: DeviceEnrollRateLimiter;
|
|
109
|
+
/** 测试可注入确定性铸造;默认 `mintDeviceId()`。 */
|
|
110
|
+
mintId?: () => string;
|
|
111
|
+
}
|
|
112
|
+
export declare function createDeviceEnrollment(deps: DeviceEnrollmentDeps): DeviceEnrollment;
|
|
113
|
+
/**
|
|
114
|
+
* 进程内固定窗限速(§7-T5 ⑤ 的**默认**实现)。
|
|
115
|
+
*
|
|
116
|
+
* 🔴 诚实边界:这是**每副本**的窗。跨副本的真限速属主是既有的 `observability/rate-limit.ts` RateGate
|
|
117
|
+
* (SQL 后端),端点车把它适配成 {@link DeviceEnrollRateLimiter} 传进来即可 —— 本函数不假装自己是
|
|
118
|
+
* 那个东西,也不在多副本部署下声称有全局上界。
|
|
119
|
+
*/
|
|
120
|
+
export declare function createFixedWindowRateLimiter(opts: {
|
|
121
|
+
limit: number;
|
|
122
|
+
windowMs: number;
|
|
123
|
+
now?: () => number;
|
|
124
|
+
}): DeviceEnrollRateLimiter;
|
|
125
|
+
//# sourceMappingURL=device-enrollment.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, DEVICE_RATE_LIMIT_CODE, assertDeviceIdShape, assertDeviceOwnerShape, assertDevicePubkeyShape, assertEnrollTokenHashShape, assertRootSessionIdShape, hashEnrollToken, mintDeviceId, } from "./device-store.js";
|
|
2
|
+
function reject(code, message, extra) {
|
|
3
|
+
return { ok: false, reject: { code, status: DEVICE_ERROR_STATUS[code], message, ...(extra?.retryAfterSec === undefined ? {} : { retryAfterSec: extra.retryAfterSec }) } };
|
|
4
|
+
}
|
|
5
|
+
function rejectEnrollment() {
|
|
6
|
+
return reject(DEVICE_ERROR_CODES.ENROLLMENT_INVALID, "enrollment token is not valid for this request");
|
|
7
|
+
}
|
|
8
|
+
function rejectNotFound() {
|
|
9
|
+
return reject(DEVICE_ERROR_CODES.NOT_FOUND, "device not found");
|
|
10
|
+
}
|
|
11
|
+
export function createDeviceEnrollment(deps) {
|
|
12
|
+
const { store } = deps;
|
|
13
|
+
const mintId = deps.mintId ?? mintDeviceId;
|
|
14
|
+
async function rateGate(key) {
|
|
15
|
+
if (!deps.rateLimiter)
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const verdict = await deps.rateLimiter.check(key);
|
|
19
|
+
if (verdict.allowed)
|
|
20
|
+
return null;
|
|
21
|
+
return reject(DEVICE_RATE_LIMIT_CODE, "too many device enrollment attempts", { retryAfterSec: verdict.retryAfterSec ?? 1 });
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return reject(DEVICE_RATE_LIMIT_CODE, `device enrollment rate check unavailable (fail-closed): ${err instanceof Error ? err.message : String(err)}`, { retryAfterSec: 1 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function enroll(input) {
|
|
28
|
+
assertDeviceOwnerShape(input.requester);
|
|
29
|
+
assertDevicePubkeyShape(input.device.pubkey);
|
|
30
|
+
const limited = await rateGate(`enroll:${input.requester.tenant}\u0000${input.requester.subject}`);
|
|
31
|
+
if (limited)
|
|
32
|
+
return limited;
|
|
33
|
+
const tokenHash = hashEnrollToken(input.token);
|
|
34
|
+
assertEnrollTokenHashShape(tokenHash);
|
|
35
|
+
const deviceId = mintId();
|
|
36
|
+
assertDeviceIdShape(deviceId);
|
|
37
|
+
const result = await store.consumeEnrollToken({
|
|
38
|
+
tokenHash,
|
|
39
|
+
requester: input.requester,
|
|
40
|
+
device: { ...input.device, deviceId, owner: input.requester },
|
|
41
|
+
});
|
|
42
|
+
switch (result.outcome) {
|
|
43
|
+
case "enrolled":
|
|
44
|
+
return { ok: true, value: { device: result.device, replayed: false } };
|
|
45
|
+
case "replayed":
|
|
46
|
+
if (!result.device)
|
|
47
|
+
return rejectEnrollment();
|
|
48
|
+
if (result.device.status === "revoked")
|
|
49
|
+
return reject(DEVICE_ERROR_CODES.REVOKED, "device has been revoked");
|
|
50
|
+
return { ok: true, value: { device: result.device, replayed: true } };
|
|
51
|
+
case "token_reuse": {
|
|
52
|
+
const row = await store.getEnrollToken(tokenHash);
|
|
53
|
+
await store.appendAudit({
|
|
54
|
+
event: "device_enroll_token_reuse",
|
|
55
|
+
deviceId: row?.consumedDeviceId ?? null,
|
|
56
|
+
owner: input.requester,
|
|
57
|
+
detail: { consumedAtMs: row?.consumedAtMs ?? null, presentedPubkeyDiffers: true },
|
|
58
|
+
});
|
|
59
|
+
return rejectEnrollment();
|
|
60
|
+
}
|
|
61
|
+
case "principal_mismatch":
|
|
62
|
+
await store.appendAudit({ event: "device_identity_mismatch", deviceId: null, owner: input.requester, detail: { at: "enrollment", reason: "token target principal mismatch" } });
|
|
63
|
+
return rejectEnrollment();
|
|
64
|
+
case "expired":
|
|
65
|
+
case "unknown_token":
|
|
66
|
+
return rejectEnrollment();
|
|
67
|
+
case "pubkey_taken":
|
|
68
|
+
await store.appendAudit({ event: "device_identity_mismatch", deviceId: null, owner: input.requester, detail: { at: "enrollment", reason: "pubkey already bound to another device in this tenant" } });
|
|
69
|
+
return rejectEnrollment();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function bindPlacement(input) {
|
|
73
|
+
assertRootSessionIdShape(input.rootSessionId);
|
|
74
|
+
assertDeviceIdShape(input.deviceId);
|
|
75
|
+
assertDeviceOwnerShape(input.requester);
|
|
76
|
+
const result = await store.bindSession({ rootSessionId: input.rootSessionId, deviceId: input.deviceId, owner: input.requester });
|
|
77
|
+
switch (result.outcome) {
|
|
78
|
+
case "bound":
|
|
79
|
+
case "idempotent":
|
|
80
|
+
return { ok: true, value: result.row };
|
|
81
|
+
case "conflict":
|
|
82
|
+
return reject(DEVICE_ERROR_CODES.CLAIM_CONFLICT, "this session is already bound to a different device");
|
|
83
|
+
case "device_not_found":
|
|
84
|
+
return rejectNotFound();
|
|
85
|
+
case "device_revoked":
|
|
86
|
+
return reject(DEVICE_ERROR_CODES.REVOKED, "device has been revoked");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function admitSession(input) {
|
|
90
|
+
assertRootSessionIdShape(input.rootSessionId);
|
|
91
|
+
assertDeviceOwnerShape(input.requester);
|
|
92
|
+
if (input.requestedDeviceId !== undefined)
|
|
93
|
+
assertDeviceIdShape(input.requestedDeviceId);
|
|
94
|
+
const binding = await store.getSessionBinding(input.rootSessionId);
|
|
95
|
+
if (!binding)
|
|
96
|
+
return reject(DEVICE_ERROR_CODES.NOT_BOUND, "this session has no device binding");
|
|
97
|
+
if (binding.owner.tenant !== input.requester.tenant || binding.owner.subject !== input.requester.subject)
|
|
98
|
+
return rejectNotFound();
|
|
99
|
+
if (input.requestedDeviceId !== undefined && input.requestedDeviceId !== binding.deviceId) {
|
|
100
|
+
return reject(DEVICE_ERROR_CODES.IDENTITY_MISMATCH, "the requested device is not the one bound to this session");
|
|
101
|
+
}
|
|
102
|
+
const view = await store.readDeviceForAdmission(binding.deviceId, input.requester);
|
|
103
|
+
if (!view)
|
|
104
|
+
return rejectNotFound();
|
|
105
|
+
if (view.row.status === "revoked")
|
|
106
|
+
return reject(DEVICE_ERROR_CODES.REVOKED, "device has been revoked");
|
|
107
|
+
if (!view.online)
|
|
108
|
+
return reject(DEVICE_ERROR_CODES.OFFLINE, "device is not connected");
|
|
109
|
+
return { ok: true, value: { deviceId: binding.deviceId, device: view.row, connGeneration: view.row.connGeneration } };
|
|
110
|
+
}
|
|
111
|
+
async function revoke(input) {
|
|
112
|
+
assertDeviceIdShape(input.deviceId);
|
|
113
|
+
assertDeviceOwnerShape(input.requester);
|
|
114
|
+
const result = await store.revokeDevice({ deviceId: input.deviceId, owner: input.requester, revokedBy: input.revokedBy, reason: input.reason ?? null });
|
|
115
|
+
switch (result.outcome) {
|
|
116
|
+
case "revoked":
|
|
117
|
+
case "already_revoked":
|
|
118
|
+
return { ok: true, value: result.row };
|
|
119
|
+
case "not_found":
|
|
120
|
+
return rejectNotFound();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { enroll, bindPlacement, admitSession, revoke };
|
|
124
|
+
}
|
|
125
|
+
export function createFixedWindowRateLimiter(opts) {
|
|
126
|
+
const now = opts.now ?? (() => Date.now());
|
|
127
|
+
const windows = new Map();
|
|
128
|
+
const MAX_WINDOWS = 10_000;
|
|
129
|
+
const prune = (t) => {
|
|
130
|
+
for (const [k, w] of windows)
|
|
131
|
+
if (t - w.startedAtMs >= opts.windowMs)
|
|
132
|
+
windows.delete(k);
|
|
133
|
+
if (windows.size <= MAX_WINDOWS)
|
|
134
|
+
return;
|
|
135
|
+
const victims = [...windows.entries()].sort((a, b) => a[1].startedAtMs - b[1].startedAtMs).slice(0, Math.floor(windows.size / 2));
|
|
136
|
+
for (const [k] of victims)
|
|
137
|
+
windows.delete(k);
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
check: async (key) => {
|
|
141
|
+
const t = now();
|
|
142
|
+
if (windows.size >= MAX_WINDOWS)
|
|
143
|
+
prune(t);
|
|
144
|
+
const cur = windows.get(key);
|
|
145
|
+
if (!cur || t - cur.startedAtMs >= opts.windowMs) {
|
|
146
|
+
windows.set(key, { startedAtMs: t, count: 1 });
|
|
147
|
+
return { allowed: true };
|
|
148
|
+
}
|
|
149
|
+
cur.count += 1;
|
|
150
|
+
if (cur.count > opts.limit)
|
|
151
|
+
return { allowed: false, retryAfterSec: Math.max(1, Math.ceil((cur.startedAtMs + opts.windowMs - t) / 1000)) };
|
|
152
|
+
return { allowed: true };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=device-enrollment.js.map
|