@andypai/agent-kanban 0.7.0 → 0.8.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.
@@ -1,9 +1,60 @@
1
1
  import { ErrorCode, KanbanError } from './errors'
2
2
  import { providerNotConfigured } from './providers/errors'
3
3
  import { resolvePollingSyncIntervalMs } from './sync-config'
4
+ import { parseDecimalDigits } from './transport-input'
4
5
 
5
6
  export type TrackerProvider = 'local' | 'linear' | 'jira'
6
7
 
8
+ /**
9
+ * Single source of truth for which env var holds each provider's webhook signing
10
+ * secret (`null` = the provider has no webhook ingestion). Typed as
11
+ * `Record<TrackerProvider, …>`, so adding a new provider to the union is a
12
+ * compile error here until its secret env is declared — preventing a new
13
+ * webhook-capable provider from silently slipping past the tunnel-security gate
14
+ * (assertTunnelSecurity) with no secret enforced.
15
+ */
16
+ export const WEBHOOK_SECRET_ENV: Record<TrackerProvider, string | null> = {
17
+ local: null,
18
+ linear: 'LINEAR_WEBHOOK_SECRET',
19
+ jira: 'JIRA_WEBHOOK_SECRET',
20
+ }
21
+
22
+ /**
23
+ * Normalize the raw `KANBAN_PROVIDER` env value to a known `TrackerProvider`,
24
+ * falling back to `'local'` for anything unset/unrecognized — matching how
25
+ * trackerConfigFromEnv resolves the provider. Centralized so the security gate
26
+ * and the config loader agree on a single derivation.
27
+ */
28
+ export function trackerProviderFromEnv(
29
+ env: Record<string, string | undefined> = process.env,
30
+ ): TrackerProvider {
31
+ const raw = (env['KANBAN_PROVIDER'] ?? 'local').trim().toLowerCase()
32
+ // Recognize exactly the providers declared in WEBHOOK_SECRET_ENV — own keys
33
+ // only, so inherited names like "constructor"/"toString" don't match — and
34
+ // fall back to local otherwise. Deriving from the map keeps this normalizer in
35
+ // lockstep with it, so a newly-added provider is picked up here automatically.
36
+ return Object.prototype.hasOwnProperty.call(WEBHOOK_SECRET_ENV, raw)
37
+ ? (raw as TrackerProvider)
38
+ : 'local'
39
+ }
40
+
41
+ /**
42
+ * Resolve a provider's webhook signing secret from `env` via WEBHOOK_SECRET_ENV
43
+ * (the single source of truth), returning `undefined` when the provider declares
44
+ * no secret env or the env var is unset — the case where the webhook handler
45
+ * falls back to open dev mode. The runtime signature enforcement (the provider
46
+ * webhook handlers) and the assertTunnelSecurity tunnel gate both resolve the
47
+ * secret env name through this same map, so the gate cannot require one env name
48
+ * while enforcement reads another (a fail-open if they drifted).
49
+ */
50
+ export function webhookSecretFromEnv(
51
+ provider: TrackerProvider,
52
+ env: Record<string, string | undefined> = process.env,
53
+ ): string | undefined {
54
+ const envName = WEBHOOK_SECRET_ENV[provider]
55
+ return envName ? env[envName] : undefined
56
+ }
57
+
7
58
  export interface LocalTrackerConfig {
8
59
  provider: 'local'
9
60
  defaultColumns?: string[]
@@ -34,7 +85,7 @@ export type TrackerConfig = LocalTrackerConfig | LinearTrackerConfig | JiraTrack
34
85
  export function trackerConfigFromEnv(
35
86
  env: Record<string, string | undefined> = process.env,
36
87
  ): TrackerConfig {
37
- const provider = (env['KANBAN_PROVIDER'] ?? 'local').trim().toLowerCase()
88
+ const provider = trackerProviderFromEnv(env)
38
89
 
39
90
  if (provider === 'linear') {
40
91
  const apiKey = env['LINEAR_API_KEY']
@@ -70,15 +121,14 @@ export function trackerConfigFromEnv(
70
121
  `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
71
122
  )
72
123
  }
73
- const boardIdRaw = env['JIRA_BOARD_ID']
74
- const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
124
+ const boardId = jiraBoardIdFromEnv(env['JIRA_BOARD_ID'])
75
125
  return {
76
126
  provider,
77
127
  baseUrl: baseUrl!,
78
128
  email: email!,
79
129
  apiToken: apiToken!,
80
130
  projectKey: projectKey!,
81
- ...(Number.isFinite(boardId) ? { boardId } : {}),
131
+ ...(boardId !== undefined ? { boardId } : {}),
82
132
  defaultIssueType: env['JIRA_ISSUE_TYPE'] ?? 'Task',
83
133
  syncIntervalMs: resolvePollingSyncIntervalMs(env['KANBAN_SYNC_INTERVAL_MS']),
84
134
  }
@@ -94,6 +144,24 @@ export function trackerConfigFromEnv(
94
144
  }
95
145
  }
96
146
 
147
+ /**
148
+ * Resolve the optional `JIRA_BOARD_ID` env var to a positive integer board id.
149
+ * Unset/blank → `undefined` (no board pinned). A set-but-invalid value throws
150
+ * `INVALID_CONFIG` rather than silently falling back: the previous
151
+ * `Number.parseInt` accepted trailing garbage and signs (`'12abc'→12`,
152
+ * `'-5'→-5`, `'1e3'→1`), quietly pinning a *wrong* board. This matches how
153
+ * resolvePollingSyncIntervalMs treats a malformed optional numeric env var.
154
+ */
155
+ function jiraBoardIdFromEnv(raw: string | undefined): number | undefined {
156
+ const value = raw?.trim()
157
+ if (!value) return undefined
158
+ const parsed = parseDecimalDigits(value)
159
+ if (parsed === null || parsed < 1) {
160
+ throw new KanbanError(ErrorCode.INVALID_CONFIG, 'JIRA_BOARD_ID must be a positive integer')
161
+ }
162
+ return parsed
163
+ }
164
+
97
165
  function defaultColumnsFromEnv(env: Record<string, string | undefined>): string[] | undefined {
98
166
  const raw = env['KANBAN_DEFAULT_COLUMNS']?.trim()
99
167
  if (!raw) return undefined
@@ -1,26 +1,58 @@
1
1
  import { ErrorCode, KanbanError } from './errors'
2
2
 
3
3
  /**
4
- * Parse an optional positive integer supplied through a transport boundary
5
- * (HTTP query string, CLI flag). Returns `undefined` when the value is absent,
6
- * and throws a `KanbanError(INVALID_ARGUMENT)` for anything that is not a
7
- * positive integer `NaN`, negatives, zero, decimals, and trailing-garbage
8
- * strings like `"5abc"`. Centralizing this keeps invalid limits from leaking
9
- * into provider logic, where they would otherwise produce inconsistent
10
- * behavior across SQL `LIMIT`, `Array.prototype.slice`, and Postgres.
4
+ * Parse a plain decimal-digit string into an exact non-negative integer, or
5
+ * `null` if it is not one. Rejects signs, decimals, and exponent/hex forms like
6
+ * `"-1"` / `"3.5"` / `"1e100"` / `"0x10"` that `Number()` would otherwise coerce
7
+ * to an integer, and rejects an over-long digit string that `Number()` rounds
8
+ * past `MAX_SAFE_INTEGER` or overflows to `Infinity`, so the result is always
9
+ * exact. Surrounding whitespace is trimmed. This is the shared digits-only
10
+ * primitive the throwing boundary/config parsers (`parseBoundedInt`,
11
+ * `resolvePollingSyncIntervalMs`, the JIRA_BOARD_ID loader) build on — keeping a
12
+ * single definition of "what counts as an integer" so they can't drift.
13
+ */
14
+ export function parseDecimalDigits(value: string): number | null {
15
+ const trimmed = value.trim()
16
+ if (!/^\d+$/.test(trimmed)) return null
17
+ const parsed = Number(trimmed)
18
+ return Number.isSafeInteger(parsed) ? parsed : null
19
+ }
20
+
21
+ /**
22
+ * Parse a required integer supplied through a transport boundary (HTTP query
23
+ * string, CLI flag) into the inclusive range `[min, max]` (max defaults to
24
+ * `MAX_SAFE_INTEGER`), via {@link parseDecimalDigits}. Throws
25
+ * `KanbanError(INVALID_ARGUMENT)` on any violation.
26
+ */
27
+ export function parseBoundedInt(
28
+ value: string,
29
+ opts: { min: number; max?: number; field: string },
30
+ ): number {
31
+ const parsed = parseDecimalDigits(value)
32
+ const max = opts.max ?? Number.MAX_SAFE_INTEGER
33
+ if (parsed === null || parsed < opts.min || parsed > max) {
34
+ const range = opts.max === undefined ? `>= ${opts.min}` : `between ${opts.min} and ${opts.max}`
35
+ throw new KanbanError(ErrorCode.INVALID_ARGUMENT, `${opts.field} must be an integer ${range}`)
36
+ }
37
+ return parsed
38
+ }
39
+
40
+ /**
41
+ * Parse an optional positive integer supplied through a transport boundary.
42
+ * Returns `undefined` when the value is absent/blank, otherwise resolves via
43
+ * {@link parseDecimalDigits} (which trims and caps at `MAX_SAFE_INTEGER`) and
44
+ * enforces `>= 1`, throwing `KanbanError(INVALID_ARGUMENT)` framed as "positive
45
+ * integer" so invalid limits don't leak into provider logic (SQL `LIMIT`,
46
+ * `slice`, PG).
11
47
  */
12
48
  export function parsePositiveInt(
13
49
  value: string | null | undefined,
14
50
  field = 'limit',
15
51
  ): number | undefined {
16
52
  if (value === null || value === undefined) return undefined
17
- const trimmed = value.trim()
18
- if (trimmed === '') return undefined
19
- // Plain decimal digits only reject signs, decimals, and exponent forms like
20
- // "1e100" that Number() would otherwise treat as integers and let escape into
21
- // SQL `LIMIT`. Cap at MAX_SAFE_INTEGER so the parsed value stays exact.
22
- const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN
23
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
53
+ if (value.trim() === '') return undefined
54
+ const parsed = parseDecimalDigits(value)
55
+ if (parsed === null || parsed < 1) {
24
56
  throw new KanbanError(
25
57
  ErrorCode.INVALID_ARGUMENT,
26
58
  `${field} must be a positive integer (received '${value}')`,
package/src/tunnel.ts CHANGED
@@ -57,15 +57,32 @@ export function startCloudflareTunnel(port: number, opts: TunnelOptions = {}): T
57
57
  ): Promise<void> => {
58
58
  if (!stream) return
59
59
  const decoder = new TextDecoder()
60
+ // Accumulate across chunks: cloudflared may split the URL across read
61
+ // boundaries, so matching each chunk in isolation can miss it.
62
+ let buffer = ''
60
63
  for await (const chunk of stream) {
61
- const text = decoder.decode(chunk as Uint8Array, { stream: true })
62
- const match = text.match(TRYCLOUDFLARE_URL)
63
- if (match) announce(match[0])
64
+ // Once announced, keep draining the pipe (so the child doesn't block on a
65
+ // full stdout buffer) but stop scanning.
66
+ if (announced) continue
67
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true })
68
+ const match = buffer.match(TRYCLOUDFLARE_URL)
69
+ if (match) {
70
+ announce(match[0])
71
+ } else if (buffer.length > 4096) {
72
+ // Bound memory while keeping a tail long enough to span a split URL.
73
+ buffer = buffer.slice(-256)
74
+ }
64
75
  }
65
76
  }
66
77
 
67
- void scanForUrl(child.stdout as ReadableStream<Uint8Array>)
68
- void scanForUrl(child.stderr as ReadableStream<Uint8Array>)
78
+ // A stream error while draining stdout/stderr (e.g. the pipe tears down as the
79
+ // child is killed) must not surface as an unhandled rejection; the child.exited
80
+ // handler below still emits the no-URL warning when nothing was announced.
81
+ const drain = (stream: ReadableStream<Uint8Array> | null | undefined): void => {
82
+ void scanForUrl(stream).catch(() => {})
83
+ }
84
+ drain(child.stdout as ReadableStream<Uint8Array>)
85
+ drain(child.stderr as ReadableStream<Uint8Array>)
69
86
 
70
87
  void child.exited.then((code) => {
71
88
  if (!announced) {