@huynle/hub 0.7.4 → 0.8.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/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Team communication policy in message delivery (HWA-34; contract docs/plans/2026-10-30-hub-workspaces-artifacts-
|
|
2
|
+
// contracts.md, Teams + HWA-33 clarification; decisions zltiwb4s, 5etngvgm, r3vv4eon, qxzrdc6c).
|
|
3
|
+
//
|
|
4
|
+
// The delivery engine (../agent/delivery.ts) calls this module before it stores anything:
|
|
5
|
+
// - prepare(userID, from, addr) once per send: resolves team addresses and returns admit(), which filters the
|
|
6
|
+
// resolved recipients through the normative rule `teamDeliveryVerdict` (packages/protocol/src/teams.ts; never
|
|
7
|
+
// re-derived here) and throws when none is left.
|
|
8
|
+
// - answer(userID, from, original, to, kind) before a reply or decline is stored.
|
|
9
|
+
//
|
|
10
|
+
// Addressing (qxzrdc6c):
|
|
11
|
+
// - `@@role#team` / `@@role.project#team` (parseTeamRoleAddress) name the sessions currently seated in that role of
|
|
12
|
+
// that active team (paused teams included), filtered by the session's project; never global role holders. The
|
|
13
|
+
// team becomes the explicit team context: only its policy decides, and a team sender must hold a seat in it
|
|
14
|
+
// (not_member). An unknown or archived team, or a role nobody holds, is not_found (like any address).
|
|
15
|
+
// - A team-less `@@role[.project]` from a session with active seats: when the role is seated (by another session)
|
|
16
|
+
// in two or more of the sender's active teams, 409 ambiguous with `candidates: [{team, address}]` (only the
|
|
17
|
+
// sender's own teams); in exactly one, it resolves to that team's seats (implicit: every shared team counts);
|
|
18
|
+
// in none, it stays a global role address. Sessions in no team, the user and apps: global, unchanged (r3vv4eon).
|
|
19
|
+
// - The SDK parseAddress never accepts `#`, so no other caller resolves a team address as a global role.
|
|
20
|
+
//
|
|
21
|
+
// Recipients: every candidate of a role (one or sendAll) is checked; send picks among the permitted ones, sendAll
|
|
22
|
+
// reaches only those. When none is permitted the send is denied with the first candidate's reason.
|
|
23
|
+
//
|
|
24
|
+
// Denials: AgentError forbidden, extra `{policy: "policy" | "external" | "not_member"}` and a fixed message that
|
|
25
|
+
// names no team, role or recipient. Before a denial is recorded the sender's message rate limit is charged (the
|
|
26
|
+
// limiter passed in), so repeated denied attempts end in rate_limited and the audit stays bounded. The `denied`
|
|
27
|
+
// hook gets IDs only (sender, recipient, team when known, kind, reason); the hub records it in the agent audit and
|
|
28
|
+
// appends teamDeniedEvent (journal family `manage`) for the Watchtower's denied counts. Nothing is stored,
|
|
29
|
+
// no hop counted, nothing sent.
|
|
30
|
+
//
|
|
31
|
+
// Replies and declines: always allowed when they answer an ask that was delivered to the replier (its recipient is
|
|
32
|
+
// the replying party; Messages.answerable enforces the same on store, so a forged or foreign ID is not_found and
|
|
33
|
+
// reaches nobody). Denied asks are never stored, so they cannot be answered. A reply to a msg is a msg under policy.
|
|
34
|
+
//
|
|
35
|
+
// Revisions and in-flight messages (decided for HWA-34): the policy is read fresh for each send (no cache across
|
|
36
|
+
// sends), so a published revision, a pause/archive or a staffing change applies to the very next message. Messages
|
|
37
|
+
// admitted earlier are not re-checked: an ask stays answerable after its edge, its team or the replier's seat goes
|
|
38
|
+
// away, its re-wake reminders continue, and pending (offline) messages are still delivered on flush.
|
|
39
|
+
//
|
|
40
|
+
// Cost: one deliveryContext read (seats of the sender and all candidates, plus their teams) per send, plus, for
|
|
41
|
+
// role addresses, one byName or one current-seat list per involved team (≤ TEAM_LIMITS.teamsPerUser). Lookups are
|
|
42
|
+
// memoised within the send only.
|
|
43
|
+
import { parseTeamRoleAddress, type TeamSeat, teamDeliveryVerdict, type DeliveryVerdict } from "../../protocol"
|
|
44
|
+
import type { DirectoryEntry, Resolved } from "../agent/directory"
|
|
45
|
+
import { type Party, partyKey } from "../agent/envelope"
|
|
46
|
+
import { AgentError } from "../agent/errors"
|
|
47
|
+
import type { EventInput } from "../agent/events"
|
|
48
|
+
import type { Message } from "../agent/messages"
|
|
49
|
+
import { parseAddress } from "../agent/names"
|
|
50
|
+
import type { DeliveryContext, TeamSeatStore } from "./seats"
|
|
51
|
+
import type { TeamStore } from "./store"
|
|
52
|
+
|
|
53
|
+
export type TeamDenialReason = "policy" | "external" | "not_member"
|
|
54
|
+
|
|
55
|
+
/** What the denied hook and the journal get: IDs and enums only. */
|
|
56
|
+
export interface TeamDenial {
|
|
57
|
+
readonly senderID: string
|
|
58
|
+
readonly recipientID?: string
|
|
59
|
+
readonly kind: "msg" | "ask" | "reply" | "decline"
|
|
60
|
+
readonly reason: TeamDenialReason
|
|
61
|
+
readonly teamID?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Recipients named through a team: `explicit` for `#team` (the verdict's team context). */
|
|
65
|
+
export interface TeamResolved extends Resolved {
|
|
66
|
+
readonly team: { readonly teamID: string; readonly explicit: boolean }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The message limiter (MessageLimiter.admit): charged before a denial is recorded. */
|
|
70
|
+
export interface PolicyLimiter {
|
|
71
|
+
admit(userID: string, from: Party, to: Party): Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One send under policy (see prepare). */
|
|
75
|
+
export interface TeamSend {
|
|
76
|
+
/** Set when the address resolved through a team; otherwise the caller resolves it as before. */
|
|
77
|
+
readonly resolved?: TeamResolved
|
|
78
|
+
/** The recipients the sender may reach, in order; throws forbidden when none is left. */
|
|
79
|
+
admit(recipients: readonly DirectoryEntry[], kind: "msg" | "ask", limiter?: PolicyLimiter): Promise<DirectoryEntry[]>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TeamPolicyDeps {
|
|
83
|
+
readonly teams: Pick<TeamStore, "byName" | "get">
|
|
84
|
+
readonly seats: Pick<TeamSeatStore, "deliveryContext" | "list">
|
|
85
|
+
/** Session lookups by ID (Directory.resolve). */
|
|
86
|
+
readonly directory: { resolve(userID: string, addr: string): Resolved }
|
|
87
|
+
/** A denial, after it was charged to the limiter: audit it and append teamDeniedEvent. Errors are logged. */
|
|
88
|
+
readonly denied?: (userID: string, denial: TeamDenial) => void | Promise<void>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The compact journal event of a denial (TEAM_EVENT_TYPES `team.denied`): IDs only, never text or role names. */
|
|
92
|
+
export const teamDeniedEvent = (d: TeamDenial): EventInput => ({
|
|
93
|
+
type: "team.denied",
|
|
94
|
+
family: "manage",
|
|
95
|
+
subject: { senderID: d.senderID, ...(d.recipientID !== undefined ? { sessionID: d.recipientID } : {}), ...(d.teamID !== undefined ? { teamID: d.teamID } : {}) },
|
|
96
|
+
data: { kind: d.kind, reason: d.reason },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const DENIED: Record<TeamDenialReason, { message: string; hint: string }> = {
|
|
100
|
+
policy: { message: "Your team's communication paths do not allow this message", hint: "Message a role your team lets you reach, or escalate to @me" },
|
|
101
|
+
external: { message: "Team members can only contact sessions in their teams", hint: "Escalate to @me, or ask the user to allow external contact for your role" },
|
|
102
|
+
not_member: { message: "You hold no seat in that team", hint: "Address the role without #team, or through a team you are in" },
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type Denied = Extract<DeliveryVerdict, { allow: false }>
|
|
106
|
+
|
|
107
|
+
const sessionParty = (entry: DirectoryEntry): Party => ({ kind: "session", sessionID: entry.sessionID, ...(entry.name ? { name: entry.name } : {}) })
|
|
108
|
+
|
|
109
|
+
export class TeamPolicy {
|
|
110
|
+
constructor(private readonly deps: TeamPolicyDeps) {}
|
|
111
|
+
|
|
112
|
+
/** Starts a send from `from` to `addr` (see the header). */
|
|
113
|
+
async prepare(userID: string, from: Party, addr: string): Promise<TeamSend> {
|
|
114
|
+
const resolved = await this.resolve(userID, from, addr)
|
|
115
|
+
const teamID = resolved?.team.explicit ? resolved.team.teamID : undefined
|
|
116
|
+
return {
|
|
117
|
+
...(resolved ? { resolved } : {}),
|
|
118
|
+
admit: async (recipients, kind, limiter) => {
|
|
119
|
+
if (from.kind !== "session" || recipients.length === 0) return [...recipients]
|
|
120
|
+
const ctx = await this.context(userID, [from.sessionID, ...recipients.map((r) => r.sessionID)])
|
|
121
|
+
const allowed: DirectoryEntry[] = []
|
|
122
|
+
let first: { entry: DirectoryEntry; verdict: Denied } | undefined
|
|
123
|
+
for (const entry of recipients) {
|
|
124
|
+
const verdict = this.verdict(ctx, from.sessionID, entry.sessionID, kind, teamID)
|
|
125
|
+
if (verdict.allow) allowed.push(entry)
|
|
126
|
+
else first ??= { entry, verdict }
|
|
127
|
+
}
|
|
128
|
+
if (allowed.length || !first) return allowed
|
|
129
|
+
return this.deny(userID, from, sessionParty(first.entry), { senderID: from.sessionID, recipientID: first.entry.sessionID, kind, reason: first.verdict.reason, ...(teamID ? { teamID } : {}) }, limiter)
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Checks a reply or decline from `from` (the original's recipient) to `to` (its sender) before it is stored. */
|
|
135
|
+
async answer(userID: string, from: Party, original: Message, to: Party, kind: "reply" | "decline", limiter?: PolicyLimiter): Promise<void> {
|
|
136
|
+
if (from.kind !== "session") return
|
|
137
|
+
// A decline of a msg is invalid (Messages.decline says so); policy does not mask that answer.
|
|
138
|
+
if (kind === "decline" && original.kind !== "ask") return
|
|
139
|
+
const replyToPermittedAsk = original.kind === "ask" && partyKey(original.recipient) === partyKey(from)
|
|
140
|
+
const ids = [from.sessionID, ...(to.kind === "session" ? [to.sessionID] : [])]
|
|
141
|
+
const base = { kind: "reply" as const, toUser: to.kind === "user", replyToPermittedAsk }
|
|
142
|
+
// @me and permitted-ask answers need no lookup (the verdict's first rules).
|
|
143
|
+
let verdict = teamDeliveryVerdict({ ...base, senderSeats: [], recipientSeats: [], teams: new Map() })
|
|
144
|
+
if (!verdict.allow || (verdict.via !== "user" && verdict.via !== "reply")) {
|
|
145
|
+
const ctx = await this.context(userID, ids)
|
|
146
|
+
verdict = teamDeliveryVerdict({ ...base, senderSeats: seatsIn(ctx, from.sessionID), recipientSeats: to.kind === "session" ? seatsIn(ctx, to.sessionID) : [], teams: ctx.teams })
|
|
147
|
+
}
|
|
148
|
+
if (verdict.allow) return
|
|
149
|
+
await this.deny(userID, from, to, { senderID: from.sessionID, ...(to.kind === "session" ? { recipientID: to.sessionID } : {}), kind, reason: verdict.reason }, limiter)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- internals ----------
|
|
153
|
+
|
|
154
|
+
private verdict(ctx: DeliveryContext, senderID: string, recipientID: string, kind: "msg" | "ask", teamID: string | undefined): DeliveryVerdict {
|
|
155
|
+
return teamDeliveryVerdict({
|
|
156
|
+
kind,
|
|
157
|
+
toUser: false,
|
|
158
|
+
replyToPermittedAsk: false,
|
|
159
|
+
senderSeats: seatsIn(ctx, senderID),
|
|
160
|
+
recipientSeats: seatsIn(ctx, recipientID),
|
|
161
|
+
...(teamID !== undefined ? { teamID } : {}),
|
|
162
|
+
teams: ctx.teams,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private context(userID: string, sessionIDs: readonly string[]): Promise<DeliveryContext> {
|
|
167
|
+
return this.deps.seats.deliveryContext(userID, [...new Set(sessionIDs)])
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Charges the limiter, records the denial (IDs only) and throws forbidden. */
|
|
171
|
+
private async deny(userID: string, from: Party, to: Party, denial: TeamDenial, limiter?: PolicyLimiter): Promise<never> {
|
|
172
|
+
await limiter?.admit(userID, from, to)
|
|
173
|
+
try {
|
|
174
|
+
await this.deps.denied?.(userID, denial)
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
console.error(`[hub] team denial record failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
177
|
+
}
|
|
178
|
+
const { message, hint } = DENIED[denial.reason]
|
|
179
|
+
throw new AgentError("forbidden", message, { hint, extra: { policy: denial.reason } })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Team resolution of `addr` (see the header); undefined: resolve it as a plain address. */
|
|
183
|
+
private async resolve(userID: string, from: Party, addr: string): Promise<TeamResolved | undefined> {
|
|
184
|
+
const team = parseTeamRoleAddress(addr)
|
|
185
|
+
if (team) {
|
|
186
|
+
const record = await this.deps.teams.byName(userID, team.team)
|
|
187
|
+
if (!record || record.state !== "active") throw new AgentError("not_found", `No session ${addr}`)
|
|
188
|
+
const sessions = await this.seated(userID, record.id, team.role, team.project)
|
|
189
|
+
if (!sessions.length) throw new AgentError("not_found", `No session ${addr}`)
|
|
190
|
+
return { address: { kind: "role", role: team.role, ...(team.project ? { project: team.project } : {}) }, sessions, team: { teamID: record.id, explicit: true } }
|
|
191
|
+
}
|
|
192
|
+
const address = parseAddress(addr)
|
|
193
|
+
if (address?.kind !== "role" || from.kind !== "session") return undefined
|
|
194
|
+
const ctx = await this.context(userID, [from.sessionID])
|
|
195
|
+
const mine = [...new Set(seatsIn(ctx, from.sessionID).filter((s) => ctx.teams.get(s.teamID)?.state === "active").map((s) => s.teamID))]
|
|
196
|
+
const matches: Array<{ teamID: string; sessions: DirectoryEntry[] }> = []
|
|
197
|
+
for (const teamID of mine) {
|
|
198
|
+
const sessions = (await this.seated(userID, teamID, address.role, address.project)).filter((e) => e.sessionID !== from.sessionID)
|
|
199
|
+
if (sessions.length) matches.push({ teamID, sessions })
|
|
200
|
+
}
|
|
201
|
+
if (matches.length > 1) {
|
|
202
|
+
const candidates: Array<{ team: string; address: string }> = []
|
|
203
|
+
for (const m of matches) {
|
|
204
|
+
const name = (await this.deps.teams.get(userID, m.teamID))?.name
|
|
205
|
+
if (name) candidates.push({ team: name, address: `@@${address.role}${address.project ? `.${address.project}` : ""}#${name}` })
|
|
206
|
+
}
|
|
207
|
+
candidates.sort((a, b) => (a.team < b.team ? -1 : 1))
|
|
208
|
+
throw new AgentError("ambiguous", `${addr} is a role in ${matches.length} of your teams`, { hint: "Name the team: @@role#team (quote # in a shell)", extra: { candidates } })
|
|
209
|
+
}
|
|
210
|
+
const only = matches[0]
|
|
211
|
+
return only ? { address, sessions: only.sessions, team: { teamID: only.teamID, explicit: false } } : undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Directory entries of the sessions currently seated in the role (and the project, when given). */
|
|
215
|
+
private async seated(userID: string, teamID: string, role: string, project: string | undefined): Promise<DirectoryEntry[]> {
|
|
216
|
+
const ids = [...new Set((await this.deps.seats.list(userID, teamID, { current: true })).filter((s) => s.role === role).map((s) => s.sessionID))]
|
|
217
|
+
const out: DirectoryEntry[] = []
|
|
218
|
+
for (const id of ids) {
|
|
219
|
+
let entry: DirectoryEntry | undefined
|
|
220
|
+
try {
|
|
221
|
+
entry = this.deps.directory.resolve(userID, id).sessions.find((e) => e.sessionID === id)
|
|
222
|
+
} catch {
|
|
223
|
+
entry = undefined // a seat of a session the directory no longer knows: nobody to deliver to
|
|
224
|
+
}
|
|
225
|
+
if (entry && (project === undefined || entry.project === project)) out.push(entry)
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const seatsIn = (ctx: DeliveryContext, sessionID: string): readonly TeamSeat[] => ctx.seats.get(sessionID) ?? []
|