@huynle/hub 0.6.0 → 0.7.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.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,235 @@
1
+ // Grants (design §5, §7; decision wvqeuis0; exploration hcw63slg): what lets a session or app token act for its
2
+ // user. Storage, holder/tenancy checks and the hourly cap; the policy is policy.ts, the check order acting.ts.
3
+ //
4
+ // - Stored per owner in the StateStore (collection "grants", keyed by id `g_<16 hex>`). Only the user creates,
5
+ // edits or revokes one (grant-routes.ts admits user actors only).
6
+ // - Holders: `ses_…` and `@name` are pinned to the session they name at creation (a grant never follows a later
7
+ // holder of the name); a reserved `@@role` follows the role (whoever holds it when the grant is used); an
8
+ // `app:<id>` holder must be one of the owner's app tokens. All must belong to the owner, at creation and on use.
9
+ // - `expires` becomes an absolute expiresAt; revoke ends a grant (kept, for the audit's "grant g_12"). Ended
10
+ // grants beyond GRANTS_KEPT are dropped, oldest first.
11
+ // - Use (acting.ts): usable() answers the active grants an actor holds for a verb on a session directory (scope:
12
+ // canonical project paths on path boundaries), re-checking owner, holder and token every time; consume() counts
13
+ // an action against maxActionsPerHour in fixed clock-hour windows (Counters), rate_limited past it.
14
+ // - The admin audit gets only {actor: owner, action: grant.*, target: id}, never holder, scope or permissions.
15
+ import { randomBytes } from "node:crypto"
16
+ import type { Counters, StateStore } from "../backend/types"
17
+ import type { AuditEntry, Store } from "../store"
18
+ import type { Directory } from "./directory"
19
+ import { AgentError } from "./errors"
20
+ import type { AgentActor, AppActor, SessionActor } from "./actor"
21
+ import { type GrantSpec, type GrantVerb, inProjects, validateGrant } from "./policy"
22
+ import { updateVersioned } from "./versioned"
23
+
24
+ /** Active (unexpired, unrevoked) grants per user. */
25
+ export const GRANTS_ACTIVE_MAX = 50
26
+ /** Ended (expired or revoked) grants each user keeps for the audit trail. */
27
+ export const GRANTS_KEPT = 200
28
+
29
+ const COLLECTION = "grants"
30
+ const HOUR_MS = 3_600_000
31
+ const ID = /^g_[0-9a-f]{16}$/
32
+
33
+ export type GrantState = "active" | "expired" | "revoked"
34
+
35
+ export interface Grant {
36
+ readonly id: string
37
+ readonly owner: string
38
+ /** As the user wrote it: `@@role`, `@name`, `ses_…` or `app:<token id>`. */
39
+ readonly holder: string
40
+ /** The session a `ses_…` or `@name` holder was pinned to at creation. */
41
+ readonly holderSessionID?: string
42
+ readonly scope: { readonly projects: readonly string[] }
43
+ readonly allow: readonly GrantVerb[]
44
+ readonly permissions: { readonly allow: readonly string[]; readonly never: readonly string[] }
45
+ readonly allowAlways: boolean
46
+ readonly maxActionsPerHour: number
47
+ readonly created: number
48
+ readonly expiresAt: number
49
+ readonly updated?: number
50
+ readonly revokedAt?: number
51
+ }
52
+
53
+ export interface GrantView extends Grant {
54
+ readonly state: GrantState
55
+ }
56
+
57
+ export interface GrantsDeps {
58
+ readonly state: StateStore
59
+ /** The hourly action counts (consume). */
60
+ readonly counters: Counters
61
+ readonly directory: Pick<Directory, "resolve">
62
+ readonly store: Pick<Store, "user" | "agentData">
63
+ /** The hub-wide admin audit (Store): summaries only. */
64
+ readonly admin?: { audit(entry: Omit<AuditEntry, "at">): void | Promise<void> }
65
+ readonly now?: () => number
66
+ }
67
+
68
+ const notFound = () => new AgentError("not_found", "No such grant")
69
+
70
+ export class Grants {
71
+ private readonly now: () => number
72
+
73
+ constructor(private readonly deps: GrantsDeps) {
74
+ this.now = deps.now ?? Date.now
75
+ }
76
+
77
+ /** Validates and stores a new grant of the owner (the holder resolved and pinned for the owner). */
78
+ async create(owner: string, input: unknown): Promise<Grant> {
79
+ this.owner(owner)
80
+ const spec = validateGrant(input)
81
+ const holderSessionID = this.pin(owner, spec.holder)
82
+ const all = await this.all(owner)
83
+ const at = this.now()
84
+ if (all.filter((g) => grantState(g, at) === "active").length >= GRANTS_ACTIVE_MAX) {
85
+ throw new AgentError("rate_limited", `At most ${GRANTS_ACTIVE_MAX} active grants per user`, { hint: "Revoke unused grants" })
86
+ }
87
+ const grant: Grant = { id: `g_${randomBytes(8).toString("hex")}`, owner, ...fields(spec, at), ...(holderSessionID ? { holderSessionID } : {}), created: at }
88
+ await this.deps.state.put(owner, COLLECTION, grant.id, grant)
89
+ await this.prune(owner, all, at)
90
+ await this.deps.admin?.audit({ actor: owner, action: "grant.create", target: grant.id })
91
+ return grant
92
+ }
93
+
94
+ /** One of the owner's grants (any state); not_found otherwise. */
95
+ async get(owner: string, id: string): Promise<Grant> {
96
+ if (!ID.test(id)) throw notFound()
97
+ const found = await this.deps.state.get<Grant>(owner, COLLECTION, id)
98
+ if (!found) throw notFound()
99
+ return found.value
100
+ }
101
+
102
+ /** The owner's grants (every state), newest first. */
103
+ async list(owner: string): Promise<GrantView[]> {
104
+ const at = this.now()
105
+ return (await this.all(owner)).sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1)).map((g) => ({ ...g, state: grantState(g, at) }))
106
+ }
107
+
108
+ /**
109
+ * Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
110
+ * stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
111
+ */
112
+ async update(owner: string, id: string, input: unknown): Promise<Grant> {
113
+ const spec = validateGrant(input)
114
+ return this.change(owner, id, "grant.update", (grant, at) => {
115
+ if (spec.holder !== grant.holder) throw new AgentError("invalid", "A grant's holder cannot change: revoke it and create another")
116
+ return { ...grant, ...fields(spec, at), updated: at }
117
+ })
118
+ }
119
+
120
+ /** Ends an active grant now; an ended one is gone. */
121
+ async revoke(owner: string, id: string): Promise<Grant> {
122
+ return this.change(owner, id, "grant.revoke", (grant, at) => ({ ...grant, revokedAt: at }))
123
+ }
124
+
125
+ /** A version-checked change of an active grant. */
126
+ private async change(owner: string, id: string, action: string, apply: (grant: Grant, at: number) => Grant): Promise<Grant> {
127
+ await this.get(owner, id)
128
+ let missing = false
129
+ const next = await updateVersioned<Grant>(this.deps.state, owner, COLLECTION, id, (current) => {
130
+ if (!current) {
131
+ missing = true
132
+ return undefined
133
+ }
134
+ const at = this.now()
135
+ const state = grantState(current, at)
136
+ if (state !== "active") throw new AgentError("gone", `The grant is ${state}`)
137
+ return apply(current, at)
138
+ })
139
+ if (missing || !next) throw notFound()
140
+ await this.deps.admin?.audit({ actor: owner, action, target: id })
141
+ return next
142
+ }
143
+
144
+ /**
145
+ * The active grants the actor holds that allow `verb` on a session in `target.directory` (newest first),
146
+ * checked now: the owner is the actor's user and still enabled; a pinned holder is this session; a role
147
+ * holder's role is held by this session now; an app holder is this token and the owner still has it.
148
+ * Users act without grants (none). The caller still applies the policy, self-tree ban and kill switches.
149
+ */
150
+ async usable(actor: AgentActor, verb: GrantVerb, target: { readonly directory: string }): Promise<Grant[]> {
151
+ if (actor.kind === "user") return []
152
+ const user = this.deps.store.user(actor.userID)
153
+ if (!user || user.disabled) return []
154
+ const at = this.now()
155
+ const out: Grant[] = []
156
+ for (const grant of await this.all(actor.userID)) {
157
+ if (grant.owner !== actor.userID || grantState(grant, at) !== "active" || !grant.allow.includes(verb)) continue
158
+ if (!inProjects(target.directory, grant.scope.projects) || !this.holds(grant, actor)) continue
159
+ out.push(grant)
160
+ }
161
+ return out.sort((a, b) => b.created - a.created)
162
+ }
163
+
164
+ /** Counts one action under the grant in the current clock hour; past maxActionsPerHour rate_limited (retryAfter). */
165
+ async consume(grant: Grant): Promise<void> {
166
+ const at = this.now()
167
+ const hour = Math.floor(at / HOUR_MS)
168
+ const count = await this.deps.counters.incr(`grant-actions:${grant.owner}:${grant.id}:${hour}`, HOUR_MS)
169
+ if (count > grant.maxActionsPerHour) {
170
+ const retryAfter = Math.max(1, Math.ceil(((hour + 1) * HOUR_MS - at) / 1000))
171
+ throw new AgentError("rate_limited", `The grant allows ${grant.maxActionsPerHour} actions an hour`, { extra: { retryAfter } })
172
+ }
173
+ }
174
+
175
+ /** The actor is the grant's holder now (same owner assumed). */
176
+ private holds(grant: Grant, actor: SessionActor | AppActor): boolean {
177
+ if (grant.holder.startsWith("app:")) {
178
+ if (actor.kind !== "app" || grant.holder !== `app:${actor.tokenID}`) return false
179
+ return (this.deps.store.agentData(grant.owner).appTokens ?? []).some((t) => t.id === actor.tokenID)
180
+ }
181
+ if (actor.kind !== "session") return false
182
+ if (grant.holderSessionID !== undefined) return grant.holderSessionID === actor.sessionID
183
+ if (!grant.holder.startsWith("@@")) return false
184
+ try {
185
+ return this.deps.directory.resolve(grant.owner, grant.holder).sessions.some((s) => s.sessionID === actor.sessionID)
186
+ } catch {
187
+ return false
188
+ }
189
+ }
190
+
191
+ private owner(owner: string): void {
192
+ const user = this.deps.store.user(owner)
193
+ if (!user || user.disabled) throw new AgentError("not_found", "No such user")
194
+ }
195
+
196
+ /** The session a ses_/@name holder names for the owner (not_found when it is not theirs); undefined for roles and apps. */
197
+ private pin(owner: string, holder: string): string | undefined {
198
+ if (holder.startsWith("app:")) {
199
+ const id = holder.slice(4)
200
+ if (!(this.deps.store.agentData(owner).appTokens ?? []).some((t) => t.id === id)) throw new AgentError("not_found", `No app token ${id}`)
201
+ return undefined
202
+ }
203
+ if (holder.startsWith("@@")) return undefined
204
+ const { sessions } = this.deps.directory.resolve(owner, holder)
205
+ return sessions[0]!.sessionID
206
+ }
207
+
208
+ private async all(owner: string): Promise<Grant[]> {
209
+ return (await this.deps.state.list<Grant>(owner, COLLECTION)).map((e) => e.value)
210
+ }
211
+
212
+ /** Drops the oldest ended grants past GRANTS_KEPT. */
213
+ private async prune(owner: string, before: readonly Grant[], at: number): Promise<void> {
214
+ const ended = before.filter((g) => grantState(g, at) !== "active").sort((a, b) => endOf(a) - endOf(b))
215
+ for (const grant of ended.slice(0, Math.max(0, ended.length - GRANTS_KEPT))) await this.deps.state.delete(owner, COLLECTION, grant.id)
216
+ }
217
+ }
218
+
219
+ /** A grant's state at a time. */
220
+ export function grantState(grant: Grant, at: number): GrantState {
221
+ if (grant.revokedAt !== undefined) return "revoked"
222
+ return at >= grant.expiresAt ? "expired" : "active"
223
+ }
224
+
225
+ const endOf = (grant: Grant): number => grant.revokedAt ?? grant.expiresAt
226
+
227
+ const fields = (spec: GrantSpec, at: number) => ({
228
+ holder: spec.holder,
229
+ scope: { projects: [...spec.scope.projects] },
230
+ allow: [...spec.allow],
231
+ permissions: { allow: [...spec.permissions.allow], never: [...spec.permissions.never] },
232
+ allowAlways: spec.allowAlways,
233
+ maxActionsPerHour: spec.maxActionsPerHour,
234
+ expiresAt: at + spec.expiresInMs,
235
+ })