kanban-lite 1.2.2 → 1.2.4

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 (39) hide show
  1. package/dist/cli.js +257 -90
  2. package/dist/extension.js +245 -78
  3. package/dist/mcp-server.js +117 -33
  4. package/dist/sdk/index.cjs +117 -32
  5. package/dist/sdk/index.mjs +117 -32
  6. package/dist/sdk/sdk/KanbanSDK.d.ts +27 -10
  7. package/dist/sdk/sdk/modules/cards.d.ts +11 -2
  8. package/dist/sdk/sdk/plugins/index.d.ts +7 -0
  9. package/dist/sdk/sdk/types.d.ts +12 -27
  10. package/dist/sdk/shared/config.d.ts +17 -1
  11. package/dist/standalone-webview/index.js +38 -38
  12. package/dist/standalone-webview/index.js.map +1 -1
  13. package/dist/standalone.js +307 -125
  14. package/package.json +1 -1
  15. package/src/cli/index.test.ts +157 -0
  16. package/src/cli/index.ts +1 -1
  17. package/src/mcp-server/index.test.ts +76 -0
  18. package/src/mcp-server/index.ts +1 -1
  19. package/src/sdk/KanbanSDK.d.ts +26 -10
  20. package/src/sdk/KanbanSDK.ts +37 -11
  21. package/src/sdk/__tests__/KanbanSDK.test.ts +79 -24
  22. package/src/sdk/integrationCatalog.ts +1 -0
  23. package/src/sdk/modules/cards.ts +13 -24
  24. package/src/sdk/plugins/index.d.ts +7 -0
  25. package/src/sdk/plugins/index.ts +17 -2
  26. package/src/sdk/types.d.ts +10 -26
  27. package/src/sdk/types.ts +11 -24
  28. package/src/sdk/webhooks.ts +19 -2
  29. package/src/shared/config.ts +130 -2
  30. package/src/standalone/__tests__/server.integration.test.ts +81 -2
  31. package/src/standalone/internal/runtime.ts +11 -6
  32. package/src/standalone/internal/websocket.ts +13 -3
  33. package/src/standalone/server.ts +67 -9
  34. package/src/standalone/watcherSetup.ts +9 -0
  35. package/src/webview/standalone-shim.ts +2 -1
  36. package/tmp/screenshots-workspace/.kanban/.active-card.json +5 -0
  37. package/tmp/screenshots-workspace/.kanban/boards/default/deleted/1-dddd.md +17 -0
  38. package/tmp/screenshots-workspace/.kanban/boards/default/deleted/attachments/1.log +1 -0
  39. package/tmp/screenshots-workspace/.kanban.json +59 -0
@@ -138,6 +138,8 @@ interface AuthPluginModule {
138
138
  readonly createRbacIdentityPlugin?: unknown
139
139
  /** Optional factory for a configurable policy plugin. When present it is called with the provider options from `.kanban.json` so plugins can apply per-deployment overrides such as a custom RBAC matrix. */
140
140
  readonly createAuthPolicyPlugin?: ((options?: Record<string, unknown>) => unknown) | unknown
141
+ /** Optional factory for a configurable identity plugin. When present it is called with the provider options from `.kanban.json` so plugins can apply per-deployment overrides such as an explicit API token. */
142
+ readonly createAuthIdentityPlugin?: ((options?: Record<string, unknown>) => unknown) | unknown
141
143
  readonly default?: unknown
142
144
  }
143
145
 
@@ -345,7 +347,7 @@ function resolveAuthIdentityPlugin(ref: ProviderRef): AuthIdentityPlugin {
345
347
  if (ref.provider === 'noop') return NOOP_IDENTITY_PLUGIN
346
348
  if (ref.provider === 'rbac') return RBAC_IDENTITY_PLUGIN
347
349
  const packageName = AUTH_PROVIDER_ALIASES.get(ref.provider) ?? ref.provider
348
- return loadExternalAuthIdentityPlugin(packageName, ref.provider)
350
+ return loadExternalAuthIdentityPlugin(packageName, ref.provider, ref.options)
349
351
  }
350
352
 
351
353
  function resolveAuthPolicyPlugin(ref: ProviderRef): AuthPolicyPlugin {
@@ -621,6 +623,13 @@ export type StandaloneHttpHandler = (request: StandaloneHttpRequestContext) => P
621
623
  * resolved the active workspace capability selections.
622
624
  */
623
625
  export interface StandaloneHttpPluginRegistrationOptions {
626
+ /**
627
+ * Active SDK instance backing the standalone runtime, when provided by the host.
628
+ *
629
+ * Plugin registration code may use the full public {@link KanbanSDK} surface,
630
+ * including `getConfigSnapshot()`, when this seam is available.
631
+ */
632
+ readonly sdk?: KanbanSDK
624
633
  /** Absolute workspace root containing `.kanban.json`. */
625
634
  readonly workspaceRoot: string
626
635
  /** Absolute workspace `.kanban` directory. */
@@ -1087,8 +1096,14 @@ function selectAuthPolicyPlugin(mod: AuthPluginModule, providerId: string): Auth
1087
1096
  return null
1088
1097
  }
1089
1098
 
1090
- function loadExternalAuthIdentityPlugin(packageName: string, providerId: string): AuthIdentityPlugin {
1099
+ function loadExternalAuthIdentityPlugin(packageName: string, providerId: string, options?: Record<string, unknown>): AuthIdentityPlugin {
1091
1100
  const mod = loadExternalModule(packageName) as AuthPluginModule
1101
+
1102
+ if (options !== undefined && typeof mod.createAuthIdentityPlugin === 'function') {
1103
+ const created = (mod.createAuthIdentityPlugin as (opts?: Record<string, unknown>) => unknown)(options)
1104
+ if (isValidAuthIdentityPlugin(created, providerId)) return created
1105
+ }
1106
+
1092
1107
  const plugin = selectAuthIdentityPlugin(mod, providerId)
1093
1108
  if (!plugin) {
1094
1109
  throw new Error(
@@ -58,7 +58,7 @@ export type SDKBeforeEventType = 'card.create' | 'card.update' | 'card.move' | '
58
58
  *
59
59
  * @see AfterEventPayload for the payload envelope passed to after-event listeners.
60
60
  */
61
- export type SDKAfterEventType = 'task.created' | 'task.updated' | 'task.moved' | 'task.deleted' | 'comment.created' | 'comment.updated' | 'comment.deleted' | 'column.created' | 'column.updated' | 'column.deleted' | 'attachment.added' | 'attachment.removed' | 'settings.updated' | 'board.created' | 'board.updated' | 'board.deleted' | 'board.action' | 'board.log.added' | 'board.log.cleared' | 'log.added' | 'log.cleared' | 'storage.migrated' | 'form.submitted' | 'auth.allowed' | 'auth.denied';
61
+ export type SDKAfterEventType = 'task.created' | 'task.updated' | 'task.moved' | 'task.deleted' | 'comment.created' | 'comment.updated' | 'comment.deleted' | 'column.created' | 'column.updated' | 'column.deleted' | 'attachment.added' | 'attachment.removed' | 'settings.updated' | 'board.created' | 'board.updated' | 'board.deleted' | 'board.action' | 'card.action.triggered' | 'board.log.added' | 'board.log.cleared' | 'log.added' | 'log.cleared' | 'storage.migrated' | 'form.submitted' | 'auth.allowed' | 'auth.denied';
62
62
  /**
63
63
  * Union of all SDK event types (before-events and after-events).
64
64
  *
@@ -468,29 +468,11 @@ export declare class CardStateError extends Error {
468
468
  constructor(code: CardStateErrorCode, message: string);
469
469
  }
470
470
  /**
471
- * Minimal SDK webhook facade supplied to CLI plugins via {@link CliPluginContext}.
472
- *
473
- * Structural subset of `KanbanSDK`; plugins should use this surface instead of
474
- * importing `KanbanSDK` directly so they remain decoupled from core internals.
471
+ * @deprecated Use {@link KanbanSDK}. CLI plugin hosts now advertise the full
472
+ * public SDK surface, including `getConfigSnapshot()`, instead of a narrowed
473
+ * webhook-only facade.
475
474
  */
476
- export interface CliPluginSdk {
477
- /**
478
- * Returns the SDK extension bag contributed by the plugin with the given id,
479
- * when the host is backed by a full `KanbanSDK` instance.
480
- *
481
- * CLI plugins should prefer this extension path when available and fall back
482
- * to compatibility methods only when running against older or mocked SDK facades.
483
- */
484
- getExtension?<T extends Record<string, unknown> = Record<string, unknown>>(id: string): T | undefined;
485
- listWebhooks(): Webhook[];
486
- createWebhook(input: {
487
- url: string;
488
- events: string[];
489
- secret?: string;
490
- }): Promise<Webhook>;
491
- updateWebhook(id: string, updates: Partial<Pick<Webhook, 'url' | 'events' | 'secret' | 'active'>>): Promise<Webhook | null>;
492
- deleteWebhook(id: string): Promise<boolean>;
493
- }
475
+ export type CliPluginSdk = KanbanSDK;
494
476
  /**
495
477
  * Runtime context supplied to a {@link KanbanCliPlugin} when it is invoked by
496
478
  * the `kl` CLI.
@@ -503,10 +485,12 @@ export interface CliPluginContext {
503
485
  *
504
486
  * Present when the plugin is invoked through the core `kl` CLI.
505
487
  * Absent in isolated unit tests or standalone invocations.
506
- * Plugins should prefer this over constructing their own SDK so that
507
- * SDK-level auth policy is honoured.
488
+ * Plugins may use the full public {@link KanbanSDK} contract here, including
489
+ * extension lookup and `getConfigSnapshot()`, instead of relying on older
490
+ * helper-only SDK facades. Plugins should prefer this over constructing their
491
+ * own SDK so that SDK-level auth policy is honoured.
508
492
  */
509
- sdk?: CliPluginSdk;
493
+ sdk?: KanbanSDK;
510
494
  /**
511
495
  * Core-owned CLI auth helper.
512
496
  *
package/src/sdk/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Card, CardFormAttachment, CardFormDataMap, Priority, ResolvedFormDescriptor } from '../shared/types'
2
2
  import type { CapabilitySelections, Webhook } from '../shared/config'
3
+ import type { KanbanSDK } from './KanbanSDK'
3
4
  import type { StorageEngine, StorageEngineType } from './plugins/types'
4
5
  import type { CardStateCursor } from './plugins'
5
6
 
@@ -118,6 +119,7 @@ export type SDKAfterEventType =
118
119
  | 'board.updated'
119
120
  | 'board.deleted'
120
121
  | 'board.action'
122
+ | 'card.action.triggered'
121
123
  | 'board.log.added'
122
124
  | 'board.log.cleared'
123
125
  | 'log.added'
@@ -614,28 +616,11 @@ export class CardStateError extends Error {
614
616
  }
615
617
 
616
618
  /**
617
- * Minimal SDK webhook facade supplied to CLI plugins via {@link CliPluginContext}.
618
- *
619
- * Structural subset of `KanbanSDK`; plugins should use this surface instead of
620
- * importing `KanbanSDK` directly so they remain decoupled from core internals.
619
+ * @deprecated Use {@link KanbanSDK}. CLI plugin hosts now advertise the full
620
+ * public SDK surface, including `getConfigSnapshot()`, instead of a narrowed
621
+ * webhook-only facade.
621
622
  */
622
- export interface CliPluginSdk {
623
- /**
624
- * Returns the SDK extension bag contributed by the plugin with the given id,
625
- * when the host is backed by a full `KanbanSDK` instance.
626
- *
627
- * CLI plugins should prefer this extension path when available and fall back
628
- * to compatibility methods only when running against older or mocked SDK facades.
629
- */
630
- getExtension?<T extends Record<string, unknown> = Record<string, unknown>>(id: string): T | undefined
631
- listWebhooks(): Webhook[]
632
- createWebhook(input: { url: string; events: string[]; secret?: string }): Promise<Webhook>
633
- updateWebhook(
634
- id: string,
635
- updates: Partial<Pick<Webhook, 'url' | 'events' | 'secret' | 'active'>>,
636
- ): Promise<Webhook | null>
637
- deleteWebhook(id: string): Promise<boolean>
638
- }
623
+ export type CliPluginSdk = KanbanSDK
639
624
 
640
625
  /**
641
626
  * Runtime context supplied to a {@link KanbanCliPlugin} when it is invoked by
@@ -649,10 +634,12 @@ export interface CliPluginContext {
649
634
  *
650
635
  * Present when the plugin is invoked through the core `kl` CLI.
651
636
  * Absent in isolated unit tests or standalone invocations.
652
- * Plugins should prefer this over constructing their own SDK so that
653
- * SDK-level auth policy is honoured.
637
+ * Plugins may use the full public {@link KanbanSDK} contract here, including
638
+ * extension lookup and `getConfigSnapshot()`, instead of relying on older
639
+ * helper-only SDK facades. Plugins should prefer this over constructing their
640
+ * own SDK so that SDK-level auth policy is honoured.
654
641
  */
655
- sdk?: CliPluginSdk
642
+ sdk?: KanbanSDK
656
643
  /**
657
644
  * Core-owned CLI auth helper.
658
645
  *
@@ -32,10 +32,15 @@ import type { SDKEventType } from './types'
32
32
  export function fireWebhooks(workspaceRoot: string, event: SDKEventType, data: unknown): void {
33
33
  const config = readConfig(workspaceRoot)
34
34
  const webhooks = config.webhooks || []
35
+ console.log(`[kanban-lite/webhooks] fireWebhooks: event=${event} | total=${webhooks.length} registered`)
35
36
  const matching = webhooks.filter(
36
37
  w => w.active && (w.events.includes('*') || w.events.includes(event))
37
38
  )
38
- if (matching.length === 0) return
39
+ if (matching.length === 0) {
40
+ console.log(`[kanban-lite/webhooks] no matching webhooks for event=${event} (${webhooks.filter(w => !w.active).length} inactive)`)
41
+ return
42
+ }
43
+ console.log(`[kanban-lite/webhooks] firing ${matching.length} webhook(s) for event=${event}: ${matching.map(w => w.id).join(', ')}`)
39
44
 
40
45
  const payload = JSON.stringify({
41
46
  event,
@@ -73,6 +78,7 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
73
78
  'X-Webhook-Event': event
74
79
  }
75
80
 
81
+ const hasSecret = !!webhook.secret
76
82
  if (webhook.secret) {
77
83
  const signature = crypto
78
84
  .createHmac('sha256', webhook.secret)
@@ -81,6 +87,10 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
81
87
  headers['X-Webhook-Signature'] = `sha256=${signature}`
82
88
  }
83
89
 
90
+ console.log(
91
+ `[kanban-lite/webhooks] → POST ${webhook.url} | event=${event} | id=${webhook.id} | secret=${hasSecret ? 'yes' : 'no'} | payloadBytes=${Buffer.byteLength(payload)}`,
92
+ )
93
+
84
94
  return new Promise((resolve, reject) => {
85
95
  const req = transport.request(
86
96
  {
@@ -92,6 +102,9 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
92
102
  timeout: 10000
93
103
  },
94
104
  (res) => {
105
+ console.log(
106
+ `[kanban-lite/webhooks] ← ${res.statusCode} ${res.statusMessage ?? ''} | id=${webhook.id} | url=${webhook.url}`,
107
+ )
95
108
  res.resume() // drain response
96
109
  if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
97
110
  resolve()
@@ -100,8 +113,12 @@ async function deliverWebhook(webhook: Webhook, event: string, payload: string):
100
113
  }
101
114
  }
102
115
  )
103
- req.on('error', reject)
116
+ req.on('error', (err) => {
117
+ console.error(`[kanban-lite/webhooks] request error | id=${webhook.id} | url=${webhook.url}:`, err.message)
118
+ reject(err)
119
+ })
104
120
  req.on('timeout', () => {
121
+ console.error(`[kanban-lite/webhooks] timeout | id=${webhook.id} | url=${webhook.url}`)
105
122
  req.destroy()
106
123
  reject(new Error('Timeout'))
107
124
  })
@@ -191,7 +191,10 @@ export interface KanbanConfig {
191
191
  webhooks?: Webhook[]
192
192
  /** Label definitions keyed by label name, with color and optional group. */
193
193
  labels?: Record<string, LabelDefinition>
194
- /** Optional URL to POST to when a card action is triggered. */
194
+ /**
195
+ * @deprecated Removed in favour of the webhook plugin. Register a webhook
196
+ * for the `card.action.triggered` event instead.
197
+ */
195
198
  actionWebhookUrl?: string
196
199
  /**
197
200
  * Global auto-increment card ID counter shared across all boards.
@@ -271,6 +274,13 @@ export interface KanbanConfig {
271
274
  customHeadHtml?: string
272
275
  /** Path to an HTML file (relative to workspace root) whose content is injected into the standalone board's `<head>` element. Takes precedence over `customHeadHtml`. */
273
276
  customHeadHtmlFile?: string
277
+ /**
278
+ * Optional URL base path prefix for subfolder reverse-proxy deployments
279
+ * (e.g. `'/kanban'`). Must start with `/` and have no trailing slash.
280
+ * When set, all asset URLs, the WebSocket endpoint, and API routes are
281
+ * served under this prefix. Only applies to the standalone server.
282
+ */
283
+ basePath?: string
274
284
  }
275
285
 
276
286
  // Legacy v1 config (for migration)
@@ -419,11 +429,105 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
419
429
  return v2
420
430
  }
421
431
 
432
+ /**
433
+ * Loads key–value pairs from a `.env` file in the given directory into
434
+ * `process.env`. Existing environment variables are never overwritten so that
435
+ * real OS-level values always take precedence over file-based defaults.
436
+ * Silently does nothing if the file does not exist.
437
+ *
438
+ * @param dir - Directory that may contain a `.env` file.
439
+ */
440
+ function loadDotEnv(dir: string): void {
441
+ const envPath = path.join(dir, '.env')
442
+ let content: string
443
+ try {
444
+ content = fs.readFileSync(envPath, 'utf-8')
445
+ } catch {
446
+ return
447
+ }
448
+ for (const line of content.split('\n')) {
449
+ const trimmed = line.trim()
450
+ if (!trimmed || trimmed.startsWith('#')) continue
451
+ const eqIdx = trimmed.indexOf('=')
452
+ if (eqIdx < 1) continue
453
+ const key = trimmed.slice(0, eqIdx).trim()
454
+ let val = trimmed.slice(eqIdx + 1).trim()
455
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
456
+ val = val.slice(1, -1)
457
+ }
458
+ if (process.env[key] === undefined) {
459
+ process.env[key] = val
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Recursively resolves `${VAR_NAME}` placeholders in all string values of a
466
+ * parsed config object against `process.env`. Mutates the object in place.
467
+ *
468
+ * Throws a descriptive error when a referenced environment variable is not
469
+ * set, including the JSON path to the offending value so the operator can
470
+ * locate it quickly. Example error message:
471
+ *
472
+ * ```
473
+ * missing ALICE_PASSWORD_HASH in .kanban.json: .plugins."auth.identity".options.users[3].password "${ALICE_PASSWORD_HASH}"
474
+ * ```
475
+ *
476
+ * Keys that contain non-identifier characters (e.g. dots) are quoted in the
477
+ * path segment, matching the convention used in `.kanban.json` error messages.
478
+ *
479
+ * @param node - The current node to process (object, array, string, or scalar).
480
+ * @param configFileName - Config filename used in error messages (e.g. `'.kanban.json'`).
481
+ * @param nodePath - JSON path accumulated so far (empty string at root).
482
+ * @returns The processed node (same reference for objects/arrays; new primitive for strings).
483
+ */
484
+ function resolveConfigEnvVars(node: unknown, configFileName: string, nodePath = ''): unknown {
485
+ const isFormDefaultDataPath = /^\.forms\.(?:[^.]+|"[^"]+")\.data(?:$|[.\[])/.test(nodePath)
486
+
487
+ if (isFormDefaultDataPath) {
488
+ return node
489
+ }
490
+
491
+ if (typeof node === 'string') {
492
+ return node.replace(/\$\{([^}]+)\}/g, (_match, varName: string) => {
493
+ const envValue = process.env[varName]
494
+ if (envValue === undefined) {
495
+ throw new Error(
496
+ `missing ${varName} in ${configFileName}: ${nodePath} "${node}"`
497
+ )
498
+ }
499
+ return envValue
500
+ })
501
+ }
502
+ if (Array.isArray(node)) {
503
+ for (let i = 0; i < node.length; i++) {
504
+ node[i] = resolveConfigEnvVars(node[i], configFileName, `${nodePath}[${i}]`)
505
+ }
506
+ return node
507
+ }
508
+ if (node !== null && typeof node === 'object') {
509
+ const obj = node as Record<string, unknown>
510
+ for (const key of Object.keys(obj)) {
511
+ const jsonKey = /[^a-zA-Z0-9_]/.test(key) ? `"${key}"` : key
512
+ const childPath = nodePath ? `${nodePath}.${jsonKey}` : `.${jsonKey}`
513
+ obj[key] = resolveConfigEnvVars(obj[key], configFileName, childPath)
514
+ }
515
+ return obj
516
+ }
517
+ return node
518
+ }
519
+
422
520
  /**
423
521
  * Reads the kanban config from disk. If the file is missing or unreadable,
424
522
  * returns the default config. If the file contains a v1 config, it is
425
523
  * automatically migrated to v2 format and persisted back to disk.
426
524
  *
525
+ * Any `${VAR_NAME}` placeholders found in string values are resolved against
526
+ * `process.env` before the config is returned. If a referenced environment
527
+ * variable is not set the process will throw a descriptive error rather than
528
+ * silently falling back to defaults, because an unresolved secret is never a
529
+ * safe default.
530
+ *
427
531
  * @param workspaceRoot - Absolute path to the workspace root directory.
428
532
  * @returns The parsed (and possibly migrated) kanban configuration.
429
533
  *
@@ -434,8 +538,32 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
434
538
  export function readConfig(workspaceRoot: string): KanbanConfig {
435
539
  const filePath = configPath(workspaceRoot)
436
540
  const defaults = { ...DEFAULT_CONFIG, boards: { default: { ...DEFAULT_BOARD_CONFIG, columns: [...DEFAULT_COLUMNS] } } }
541
+
542
+ // Parse the file first; fall back to defaults only for read/parse failures.
543
+ let raw: Record<string, unknown>
544
+ try {
545
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
546
+ } catch {
547
+ return defaults
548
+ }
549
+
550
+ // Load .env from workspace root before resolving placeholders so that
551
+ // variables defined there are available without requiring the operator to
552
+ // export them in their shell.
553
+ loadDotEnv(workspaceRoot)
554
+
555
+ // Resolve ${VAR_NAME} env placeholders. A missing env variable is a known,
556
+ // operator-caused misconfiguration and should produce a clean, actionable
557
+ // error rather than a Node.js stack trace.
558
+ try {
559
+ resolveConfigEnvVars(raw, CONFIG_FILENAME)
560
+ } catch (err) {
561
+ const msg = err instanceof Error ? err.message : String(err)
562
+ process.stderr.write(`\nConfiguration error: ${msg}\n\nSet the missing environment variable before starting the server.\n\n`)
563
+ process.exit(1)
564
+ }
565
+
437
566
  try {
438
- const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
439
567
  // True v1: explicitly version 1, OR version absent AND no boards object
440
568
  // A versionless modern config (has a boards object) must NOT be treated as v1
441
569
  const isV1 = raw.version === 1 || (!raw.version && !(typeof raw.boards === 'object' && raw.boards !== null && !Array.isArray(raw.boards)))
@@ -350,6 +350,86 @@ describe('Standalone Server Integration', () => {
350
350
  }
351
351
  })
352
352
 
353
+ it('passes the resolved public SDK to standalone plugin registration and request contexts', async () => {
354
+ const cleanup = installTempPackage(
355
+ 'standalone-sdk-context-test-plugin',
356
+ `module.exports = {
357
+ authIdentityPlugin: {
358
+ manifest: { id: 'standalone-sdk-context-test-plugin', provides: ['auth.identity'] },
359
+ async resolveIdentity() {
360
+ return { subject: 'standalone-sdk-context-test-plugin' }
361
+ },
362
+ },
363
+ standaloneHttpPlugin: {
364
+ manifest: { id: 'standalone-sdk-context-test-plugin', provides: ['standalone.http'] },
365
+ registerMiddleware() {
366
+ return [async (request) => {
367
+ if (!request.route('GET', '/api/plugin-sdk-context')) return false
368
+ request.mergeAuthContext({ actorHint: 'middleware-auth-context' })
369
+ return false
370
+ }]
371
+ },
372
+ registerRoutes(options) {
373
+ return [async (request) => {
374
+ if (!request.route('GET', '/api/plugin-sdk-context')) return false
375
+ const registrationSnapshot = options.sdk ? options.sdk.getConfigSnapshot() : null
376
+ const requestSnapshot = request.sdk.getConfigSnapshot()
377
+ const registrationBoard = options.sdk ? options.sdk.getBoard('default') : null
378
+ const requestBoard = request.sdk.getBoard('default')
379
+ request.res.statusCode = 200
380
+ request.res.setHeader('Content-Type', 'application/json')
381
+ request.res.end(JSON.stringify({
382
+ ok: true,
383
+ registrationSdkAvailable: !!options.sdk,
384
+ registrationPort: registrationSnapshot ? registrationSnapshot.port ?? null : null,
385
+ registrationBoardName: registrationBoard ? registrationBoard.name : null,
386
+ requestPort: requestSnapshot.port ?? null,
387
+ requestBoardName: requestBoard.name,
388
+ workspaceRootMatches: options.workspaceRoot === request.workspaceRoot,
389
+ kanbanDirMatches: options.kanbanDir === request.kanbanDir,
390
+ actorHint: request.getAuthContext().actorHint ?? null,
391
+ }))
392
+ return true
393
+ }]
394
+ },
395
+ },
396
+ }
397
+ `,
398
+ )
399
+
400
+ const workspaceRoot = path.dirname(tempDir)
401
+ const resolvedConfigPath = writeWorkspaceConfig(workspaceRoot, {
402
+ port,
403
+ auth: {
404
+ 'auth.identity': { provider: 'standalone-sdk-context-test-plugin' },
405
+ 'auth.policy': { provider: 'noop' },
406
+ },
407
+ })
408
+
409
+ const localPort = await getPort()
410
+ const localServer = startServer(tempDir, localPort, webviewDir, resolvedConfigPath)
411
+ await sleep(200)
412
+
413
+ try {
414
+ const res = await httpGet(`http://localhost:${localPort}/api/plugin-sdk-context`)
415
+ expect(res.status).toBe(200)
416
+ expect(JSON.parse(res.body)).toEqual({
417
+ ok: true,
418
+ registrationSdkAvailable: true,
419
+ registrationPort: port,
420
+ registrationBoardName: 'Default',
421
+ requestPort: port,
422
+ requestBoardName: 'Default',
423
+ workspaceRootMatches: true,
424
+ kanbanDirMatches: true,
425
+ actorHint: 'middleware-auth-context',
426
+ })
427
+ } finally {
428
+ cleanup()
429
+ await new Promise<void>((resolve) => localServer.close(() => resolve()))
430
+ }
431
+ })
432
+
353
433
  it('starts even when the Swagger UI package logo file is unavailable', async () => {
354
434
  vi.resetModules()
355
435
 
@@ -2561,7 +2641,7 @@ describe('Standalone Server Integration', () => {
2561
2641
  const kanbanJson = path.join(path.dirname(tempDir), '.kanban.json')
2562
2642
  fs.writeFileSync(kanbanJson, JSON.stringify({
2563
2643
  version: 2,
2564
- boards: { default: { name: 'Default', columns: [], nextCardId: 1, defaultStatus: 'backlog', defaultPriority: 'medium' } },
2644
+ boards: { default: { name: 'Default', columns: [{ id: 'backlog', name: 'Backlog' }], nextCardId: 1, defaultStatus: 'backlog', defaultPriority: 'medium' } },
2565
2645
  defaultBoard: 'default',
2566
2646
  kanbanDirectory: '.kanban',
2567
2647
  forms: {
@@ -2585,7 +2665,6 @@ describe('Standalone Server Integration', () => {
2585
2665
  }
2586
2666
  }, null, 2), 'utf-8')
2587
2667
 
2588
-
2589
2668
  const createRes = await httpRequest('POST', `http://localhost:${port}/api/tasks`, {
2590
2669
  content: '# Interpolation Regression Card',
2591
2670
  status: 'backlog',
@@ -6,20 +6,25 @@ import { KanbanSDK } from '../../sdk/KanbanSDK'
6
6
  import type { StandaloneContext } from '../context'
7
7
  import { broadcastLogsUpdatedToEditingClients, getClientsEditingCard } from '../broadcastService'
8
8
 
9
- export const indexHtml = `<!DOCTYPE html>
9
+ export function getIndexHtml(basePath = ''): string {
10
+ return `<!DOCTYPE html>
10
11
  <html lang="en">
11
12
  <head>
12
13
  <meta charset="UTF-8">
13
14
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
15
- <link href="/style.css" rel="stylesheet">
15
+ <link rel="icon" type="image/svg+xml" href="${basePath}/favicon.svg">
16
+ <link href="${basePath}/style.css" rel="stylesheet">
16
17
  <title>Kanban Board</title>
18
+ <script>window.__KB_BASE__ = ${JSON.stringify(basePath)}<\/script>
17
19
  </head>
18
20
  <body>
19
21
  <div id="root"></div>
20
- <script type="module" src="/index.js"></script>
22
+ <script type="module" src="${basePath}/index.js"><\/script>
21
23
  </body>
22
24
  </html>`
25
+ }
26
+
27
+ export const indexHtml = getIndexHtml()
23
28
 
24
29
  export interface StandaloneRuntime {
25
30
  absoluteKanbanDir: string
@@ -46,13 +51,13 @@ function resolveStandaloneWebviewDir(webviewDir?: string): string {
46
51
  return candidates[0]
47
52
  }
48
53
 
49
- export function createStandaloneRuntime(kanbanDir: string, webviewDir?: string, httpServer?: http.Server): StandaloneRuntime {
54
+ export function createStandaloneRuntime(kanbanDir: string, webviewDir?: string, httpServer?: http.Server, basePath?: string): StandaloneRuntime {
50
55
  const absoluteKanbanDir = path.resolve(kanbanDir)
51
56
  const workspaceRoot = path.dirname(absoluteKanbanDir)
52
57
  const resolvedWebviewDir = resolveStandaloneWebviewDir(webviewDir)
53
58
 
54
59
  const server = httpServer ?? http.createServer()
55
- const wss = new WebSocketServer({ server, path: '/ws' })
60
+ const wss = new WebSocketServer({ server, path: (basePath || '') + '/ws' })
56
61
 
57
62
  const ctx = {} as StandaloneContext
58
63
  const sdk = new KanbanSDK(absoluteKanbanDir, {
@@ -1,11 +1,21 @@
1
+ import * as http from 'http'
1
2
  import { extractAuthContext, getAuthErrorLike } from '../authUtils'
3
+ import type { AuthContext } from '../../sdk/types'
2
4
  import type { StandaloneContext } from '../context'
3
5
  import { clearClientEditingCard, setClientEditingCard } from '../broadcastService'
4
6
  import { handleMessage } from '../messageHandlers'
5
7
 
6
- export function attachWebSocketHandlers(ctx: StandaloneContext): void {
7
- ctx.wss.on('connection', (ws, req) => {
8
- const authContext = extractAuthContext(req)
8
+ export function attachWebSocketHandlers(
9
+ ctx: StandaloneContext,
10
+ resolveAuthContext?: (req: http.IncomingMessage) => Promise<AuthContext>,
11
+ ): void {
12
+ ctx.wss.on('connection', async (ws, req) => {
13
+ let authContext: AuthContext
14
+ try {
15
+ authContext = resolveAuthContext ? await resolveAuthContext(req) : extractAuthContext(req)
16
+ } catch {
17
+ authContext = extractAuthContext(req)
18
+ }
9
19
  setClientEditingCard(ctx, ws, null)
10
20
  ws.on('message', (data) => {
11
21
  let message: unknown