dsh-coding-subscription-oauth 0.6.0 → 0.6.3

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 (121) hide show
  1. package/CHANGELOG.md +236 -206
  2. package/CONTRIBUTING.md +129 -120
  3. package/INSTALL.md +221 -220
  4. package/LICENSE +19 -19
  5. package/NOTICE +11 -11
  6. package/README.de.md +292 -293
  7. package/README.es.md +293 -294
  8. package/README.fr.md +293 -294
  9. package/README.ja.md +293 -294
  10. package/README.ko.md +293 -294
  11. package/README.md +307 -308
  12. package/README.pt-BR.md +293 -294
  13. package/README.ru.md +293 -294
  14. package/README.zh-CN.md +305 -306
  15. package/compatibility/dsh-bom.json +17 -30
  16. package/cordis.patch.yml +13 -13
  17. package/docs/00-project-rules.md +212 -195
  18. package/docs/02-architecture.md +130 -130
  19. package/docs/02-architecture.zh-CN.md +130 -130
  20. package/lib/adapter.d.ts.map +1 -1
  21. package/lib/alias-adapter.d.ts +3 -1
  22. package/lib/alias-adapter.d.ts.map +1 -1
  23. package/lib/bin.js +3 -3
  24. package/lib/bin.js.map +2 -2
  25. package/lib/capability-settings.d.ts +8 -2
  26. package/lib/capability-settings.d.ts.map +1 -1
  27. package/lib/capability-tools.d.ts +7 -2
  28. package/lib/capability-tools.d.ts.map +1 -1
  29. package/lib/client.js +3 -3
  30. package/lib/client.js.map +4 -4
  31. package/lib/codex-images.d.ts +8 -0
  32. package/lib/codex-images.d.ts.map +1 -1
  33. package/lib/compatibility.d.ts +14 -14
  34. package/lib/dsh-host-adapter.d.ts.map +1 -1
  35. package/lib/index.d.ts +1 -1
  36. package/lib/index.d.ts.map +1 -1
  37. package/lib/index.js +141 -86
  38. package/lib/index.js.map +3 -3
  39. package/lib/invariant.js.map +1 -1
  40. package/media/en/settings_gateway.png +0 -0
  41. package/media/zh-CN/settings_gateway.png +0 -0
  42. package/package.json +185 -185
  43. package/patches/dsh-agy@0.1.2.patch +25 -25
  44. package/scripts/release.mjs +186 -186
  45. package/scripts/smoke-deployed-routes.mjs +146 -146
  46. package/scripts/verify-deployed-catalog.mjs +87 -87
  47. package/src/adapter.ts +348 -287
  48. package/src/alias-adapter.ts +147 -153
  49. package/src/auth-routes.ts +921 -921
  50. package/src/auth.ts +67 -67
  51. package/src/bin.ts +350 -350
  52. package/src/capability-routes.ts +279 -279
  53. package/src/capability-runtime.ts +314 -313
  54. package/src/capability-settings.ts +671 -658
  55. package/src/capability-tools.ts +685 -666
  56. package/src/catalog.ts +271 -271
  57. package/src/client/GrokBuildSettings.tsx +771 -770
  58. package/src/client/api.ts +88 -88
  59. package/src/client/components/AboutTab.tsx +30 -30
  60. package/src/client/components/AccountsTab.tsx +241 -241
  61. package/src/client/components/Badge.tsx +33 -33
  62. package/src/client/components/CapabilitiesTab.tsx +265 -265
  63. package/src/client/components/CliPullPreview.tsx +116 -116
  64. package/src/client/components/CopyButton.tsx +57 -57
  65. package/src/client/components/GatewayTab.tsx +469 -469
  66. package/src/client/components/NoticeBanner.tsx +46 -46
  67. package/src/client/components/ProgressBar.tsx +53 -53
  68. package/src/client/components/ProviderCard.tsx +606 -606
  69. package/src/client/components/SettingsTabs.tsx +75 -75
  70. package/src/client/components/ToggleSwitch.tsx +71 -71
  71. package/src/client/constants.ts +230 -224
  72. package/src/client/display.ts +61 -61
  73. package/src/client/dshClientAdapter.ts +127 -109
  74. package/src/client/gatewaySnippets.ts +37 -37
  75. package/src/client/index.tsx +156 -156
  76. package/src/client/locales.ts +540 -535
  77. package/src/client/microStyles.ts +52 -52
  78. package/src/client/parsers.ts +398 -396
  79. package/src/client/styles.ts +325 -325
  80. package/src/client/types.ts +199 -197
  81. package/src/codex-http.ts +447 -447
  82. package/src/codex-images.ts +503 -485
  83. package/src/codex-model-capabilities.ts +320 -320
  84. package/src/codex-search.ts +245 -245
  85. package/src/codex-usage.ts +263 -263
  86. package/src/compatibility.ts +55 -55
  87. package/src/dsh-host-adapter.ts +173 -171
  88. package/src/gateway-anthropic-messages.ts +84 -84
  89. package/src/gateway-auth.ts +102 -102
  90. package/src/gateway-backend.ts +274 -274
  91. package/src/gateway-body.ts +49 -49
  92. package/src/gateway-config.ts +76 -76
  93. package/src/gateway-http.ts +104 -104
  94. package/src/gateway-openai-chat.ts +124 -124
  95. package/src/gateway-openai-responses.ts +53 -53
  96. package/src/gateway-parse.ts +224 -224
  97. package/src/gateway-protocol.ts +52 -52
  98. package/src/gateway-routes.ts +158 -158
  99. package/src/gateway.ts +258 -258
  100. package/src/grok-errors.ts +24 -24
  101. package/src/grok-imagine.ts +1627 -1627
  102. package/src/grok-import.ts +151 -151
  103. package/src/http-json.ts +82 -82
  104. package/src/ids.ts +59 -59
  105. package/src/imagine-routes.ts +463 -463
  106. package/src/index.ts +735 -748
  107. package/src/invariant.ts +17 -17
  108. package/src/kimi-errors.ts +26 -26
  109. package/src/media-store.ts +927 -927
  110. package/src/oauth-import-routes.ts +324 -324
  111. package/src/oauth-providers.ts +152 -152
  112. package/src/oauth-session.ts +183 -183
  113. package/src/oauth-sources.ts +1104 -1104
  114. package/src/oauth.ts +620 -620
  115. package/src/provider.ts +128 -128
  116. package/src/proxy.ts +11 -11
  117. package/src/redact.ts +72 -72
  118. package/src/session.ts +218 -218
  119. package/src/store.ts +217 -217
  120. package/src/web-origin.ts +296 -296
  121. package/src/web-routes.ts +38 -38
@@ -1,658 +1,671 @@
1
- import Schema from "@deepseek-ai/schemastery";
2
- import { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
3
-
4
- /**
5
- * Integration-ready capability settings controller for the
6
- * `coding-subscription-oauth` namespace. Schema defaults sit under the
7
- * composition/YAML `base`, and the user section layers on top. The controller
8
- * talks to an injected structural settings service while registering a real
9
- * Schemastery section that the Host settings service can render and validate.
10
- * @module dsh-coding-subscription-oauth/capability-settings
11
- */
12
-
13
- /** Settings namespace owned by this plugin. */
14
- export { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
15
-
16
- /** Default-off capability flags. Presence in the user section marks an override. */
17
- export const CAPABILITY_FLAG_KEYS = [
18
- "codexSearch",
19
- "codexImages",
20
- "codexImageEdits",
21
- "codexUsage",
22
- "codexFast",
23
- "grokImagineImage",
24
- "grokImagineVideo",
25
- ] as const;
26
-
27
- /** Conservative numeric limits persisted beside the flags. */
28
- export const CAPABILITY_LIMIT_KEYS = ["searchResults", "imageCount", "videoArtifactTtlMs"] as const;
29
-
30
- /** Every key the controller admits into secret-free state. */
31
- export const CAPABILITY_SETTINGS_KEYS = [...CAPABILITY_FLAG_KEYS, ...CAPABILITY_LIMIT_KEYS] as const;
32
-
33
- export type CapabilityFlagKey = (typeof CAPABILITY_FLAG_KEYS)[number];
34
- export type CapabilityLimitKey = (typeof CAPABILITY_LIMIT_KEYS)[number];
35
- export type CapabilitySettingsKey = (typeof CAPABILITY_SETTINGS_KEYS)[number];
36
-
37
- /** Resolved, secret-free capability section. */
38
- export interface CapabilitySettings {
39
- readonly codexSearch: boolean;
40
- readonly codexImages: boolean;
41
- readonly codexImageEdits: boolean;
42
- readonly codexUsage: boolean;
43
- readonly codexFast: boolean;
44
- readonly grokImagineImage: boolean;
45
- readonly grokImagineVideo: boolean;
46
- readonly searchResults: number;
47
- readonly imageCount: number;
48
- readonly videoArtifactTtlMs: number;
49
- }
50
-
51
- /** Sparse overlay used for YAML/composition `base` and the user section. */
52
- export type CapabilitySettingsPatch = Partial<CapabilitySettings>;
53
-
54
- /** Inclusive bounds and schema defaults for each numeric limit. */
55
- export const CAPABILITY_SETTINGS_BOUNDS = {
56
- searchResults: { min: 1, max: 20, default: 5 },
57
- imageCount: { min: 1, max: 4, default: 1 },
58
- videoArtifactTtlMs: {
59
- min: 60 * 60 * 1000,
60
- max: 7 * 24 * 60 * 60 * 1000,
61
- default: 7 * 24 * 60 * 60 * 1000,
62
- },
63
- } as const;
64
-
65
- /** Schema defaults: every flag off, every limit at its conservative default. */
66
- export const DEFAULT_CAPABILITY_SETTINGS: CapabilitySettings = Object.freeze({
67
- codexSearch: false,
68
- codexImages: false,
69
- codexImageEdits: false,
70
- codexUsage: false,
71
- codexFast: false,
72
- grokImagineImage: false,
73
- grokImagineVideo: false,
74
- searchResults: CAPABILITY_SETTINGS_BOUNDS.searchResults.default,
75
- imageCount: CAPABILITY_SETTINGS_BOUNDS.imageCount.default,
76
- videoArtifactTtlMs: CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default,
77
- });
78
-
79
- /**
80
- * Real Schemastery schema registered with the Host settings service. Defaults
81
- * remain conservative, and bounds are enforced before a user document commits.
82
- */
83
- export const CapabilitySettingsSchema = Schema.object({
84
- codexSearch: Schema.boolean().default(false),
85
- codexImages: Schema.boolean().default(false),
86
- codexImageEdits: Schema.boolean().default(false),
87
- codexUsage: Schema.boolean().default(false),
88
- codexFast: Schema.boolean().default(false),
89
- grokImagineImage: Schema.boolean().default(false),
90
- grokImagineVideo: Schema.boolean().default(false),
91
- searchResults: Schema.number()
92
- .step(1)
93
- .min(CAPABILITY_SETTINGS_BOUNDS.searchResults.min)
94
- .max(CAPABILITY_SETTINGS_BOUNDS.searchResults.max)
95
- .default(CAPABILITY_SETTINGS_BOUNDS.searchResults.default),
96
- imageCount: Schema.number()
97
- .step(1)
98
- .min(CAPABILITY_SETTINGS_BOUNDS.imageCount.min)
99
- .max(CAPABILITY_SETTINGS_BOUNDS.imageCount.max)
100
- .default(CAPABILITY_SETTINGS_BOUNDS.imageCount.default),
101
- videoArtifactTtlMs: Schema.number()
102
- .step(1)
103
- .min(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.min)
104
- .max(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.max)
105
- .default(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default),
106
- });
107
-
108
- /** Serialized schema metadata consumed by Settings UI tests and diagnostics. */
109
- export const CAPABILITY_SETTINGS_SCHEMA_JSON = CapabilitySettingsSchema.toJSON();
110
-
111
- export type CapabilitySettingsSchemaType = typeof CapabilitySettingsSchema;
112
-
113
- /** Revision-bearing, secret-free snapshot used for CAS writes and UI. */
114
- export interface CapabilitySettingsSnapshot {
115
- readonly ns: typeof CAPABILITY_SETTINGS_NAMESPACE;
116
- readonly value: CapabilitySettings;
117
- readonly base?: CapabilitySettingsPatch;
118
- readonly user?: CapabilitySettingsPatch;
119
- readonly revision: number;
120
- readonly writable: boolean;
121
- readonly applies: "live";
122
- readonly secrets: readonly [];
123
- }
124
-
125
- /** Owner-facing subset of `ctx.settings.register()` used when the parent injects a provider. */
126
- export interface CapabilitySettingsScope {
127
- get(): unknown;
128
- watch(callback: (next: unknown, prev: unknown) => void | Promise<void>): () => void;
129
- update(patch: object): Promise<void>;
130
- replace(section: object): Promise<void>;
131
- }
132
-
133
- /** One namespace descriptor as returned by a structural `describe()`. */
134
- export interface CapabilitySettingsDescriptor {
135
- readonly ns: string;
136
- readonly value?: unknown;
137
- readonly base?: unknown;
138
- readonly user?: unknown;
139
- readonly revision?: number;
140
- readonly applies?: "live" | "restart";
141
- readonly secrets?: readonly { readonly path?: readonly string[]; readonly set?: boolean }[];
142
- }
143
-
144
- /**
145
- * Duck-typed settings service. A real `ctx.settings` satisfies this without a
146
- * compile-time dependency on `@deepseek-ai/dsh-settings`.
147
- */
148
- export interface CapabilitySettingsService {
149
- readonly writable?: boolean;
150
- describe?(options?: { readonly redactSecrets?: boolean }): readonly CapabilitySettingsDescriptor[];
151
- get?(ns: string): unknown;
152
- update?(ns: string, patch: object, expectedRevision?: number): Promise<void>;
153
- replace?(ns: string, section: object, expectedRevision?: number): Promise<void>;
154
- register?(
155
- ns: string,
156
- schema: CapabilitySettingsSchemaType,
157
- options?: {
158
- readonly base?: CapabilitySettingsPatch;
159
- readonly applies?: "live" | "restart";
160
- readonly validate?: (value: CapabilitySettings) => void;
161
- },
162
- ): CapabilitySettingsScope;
163
- }
164
-
165
- /** Construction options. `base` is the YAML / composition entry layered under the user section. */
166
- export interface CapabilitySettingsControllerOptions {
167
- readonly settings?: CapabilitySettingsService | undefined;
168
- readonly base?: CapabilitySettingsPatch | undefined;
169
- /** Contain both synchronous and asynchronous observer failures. */
170
- readonly onListenerError?: ((error: unknown) => void) | undefined;
171
- }
172
-
173
- /** Listener invoked after a committed snapshot change. */
174
- export type CapabilitySettingsListener = (snapshot: CapabilitySettingsSnapshot) => void | Promise<void>;
175
-
176
- const SECRET_KEY = /secret|token|password|passphrase|apikey|api_key|authorization|credential|cookie|private[_-]?key/iu;
177
- const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
178
- const KNOWN_KEYS = new Set<string>(CAPABILITY_SETTINGS_KEYS);
179
-
180
- /**
181
- * A write refused because the namespace moved since the caller read it.
182
- * `code` matches the Host settings seam so a later wire layer can map it.
183
- */
184
- export class CapabilitySettingsConflictError extends Error {
185
- readonly code = "SETTINGS_CONFLICT";
186
- readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
187
- readonly expected: number;
188
- readonly actual: number;
189
-
190
- constructor(expected: number, actual: number) {
191
- super(
192
- `settings namespace "${CAPABILITY_SETTINGS_NAMESPACE}" changed since it was read ` +
193
- `(expected revision ${String(expected)}, now ${String(actual)})`,
194
- );
195
- this.name = "CapabilitySettingsConflictError";
196
- this.expected = expected;
197
- this.actual = actual;
198
- }
199
- }
200
-
201
- /** A write refused because no writable settings provider is attached. */
202
- export class CapabilitySettingsReadOnlyError extends Error {
203
- readonly code: "SETTINGS_PROVIDER_ABSENT" | "SETTINGS_READ_ONLY" | "SETTINGS_DISPOSED";
204
- readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
205
- readonly reason: "absent" | "read-only" | "disposed";
206
-
207
- constructor(reason: "absent" | "read-only" | "disposed") {
208
- const code =
209
- reason === "absent"
210
- ? "SETTINGS_PROVIDER_ABSENT"
211
- : reason === "disposed"
212
- ? "SETTINGS_DISPOSED"
213
- : "SETTINGS_READ_ONLY";
214
- const detail =
215
- reason === "absent"
216
- ? `settings provider is absent: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
217
- : reason === "disposed"
218
- ? `settings controller is disposed: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
219
- : `settings provider is read-only: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated in-process`;
220
- super(detail);
221
- this.name = "CapabilitySettingsReadOnlyError";
222
- this.code = code;
223
- this.reason = reason;
224
- }
225
- }
226
-
227
- export function isCapabilitySettingsConflictError(error: unknown): error is CapabilitySettingsConflictError {
228
- return error instanceof CapabilitySettingsConflictError;
229
- }
230
-
231
- export function isCapabilitySettingsReadOnlyError(error: unknown): error is CapabilitySettingsReadOnlyError {
232
- return error instanceof CapabilitySettingsReadOnlyError;
233
- }
234
-
235
- /** Pick every independently default-off flag from a resolved section. */
236
- export function capabilityFlags(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityFlagKey> {
237
- return {
238
- codexSearch: settings.codexSearch,
239
- codexImages: settings.codexImages,
240
- codexImageEdits: settings.codexImageEdits,
241
- codexUsage: settings.codexUsage,
242
- codexFast: settings.codexFast,
243
- grokImagineImage: settings.grokImagineImage,
244
- grokImagineVideo: settings.grokImagineVideo,
245
- };
246
- }
247
-
248
- /** Pick the conservative numeric limits from a resolved section. */
249
- export function capabilityLimits(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityLimitKey> {
250
- return {
251
- searchResults: settings.searchResults,
252
- imageCount: settings.imageCount,
253
- videoArtifactTtlMs: settings.videoArtifactTtlMs,
254
- };
255
- }
256
-
257
- /** Layer schema defaults, then YAML/composition `base`, then the user section. */
258
- export function resolveCapabilitySettings(
259
- base?: CapabilitySettingsPatch | undefined,
260
- user?: CapabilitySettingsPatch | undefined,
261
- ): CapabilitySettings {
262
- return normalizeCapabilitySettings({
263
- ...DEFAULT_CAPABILITY_SETTINGS,
264
- ...normalizeCapabilitySettingsPatch(base),
265
- ...normalizeCapabilitySettingsPatch(user),
266
- });
267
- }
268
-
269
- /**
270
- * Admit a candidate section: known keys only, flags default off, limits clamped,
271
- * secret-shaped keys dropped. Used for both reads and the structural schema.
272
- */
273
- export function normalizeCapabilitySettings(input?: unknown): CapabilitySettings {
274
- const patch = normalizeCapabilitySettingsPatch(input);
275
- return Object.freeze({
276
- codexSearch: patch.codexSearch ?? DEFAULT_CAPABILITY_SETTINGS.codexSearch,
277
- codexImages: patch.codexImages ?? DEFAULT_CAPABILITY_SETTINGS.codexImages,
278
- codexImageEdits: patch.codexImageEdits ?? DEFAULT_CAPABILITY_SETTINGS.codexImageEdits,
279
- codexUsage: patch.codexUsage ?? DEFAULT_CAPABILITY_SETTINGS.codexUsage,
280
- codexFast: patch.codexFast ?? DEFAULT_CAPABILITY_SETTINGS.codexFast,
281
- grokImagineImage: patch.grokImagineImage ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineImage,
282
- grokImagineVideo: patch.grokImagineVideo ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineVideo,
283
- searchResults: patch.searchResults ?? DEFAULT_CAPABILITY_SETTINGS.searchResults,
284
- imageCount: patch.imageCount ?? DEFAULT_CAPABILITY_SETTINGS.imageCount,
285
- videoArtifactTtlMs: patch.videoArtifactTtlMs ?? DEFAULT_CAPABILITY_SETTINGS.videoArtifactTtlMs,
286
- });
287
- }
288
-
289
- /**
290
- * Normalize a sparse overlay. Invalid or secret fields are omitted so a lower
291
- * layer (YAML base / schema default) remains authoritative for that key.
292
- */
293
- export function normalizeCapabilitySettingsPatch(input?: unknown): CapabilitySettingsPatch {
294
- if (!isPlainObject(input)) return {};
295
- const patch: {
296
- codexSearch?: boolean;
297
- codexImages?: boolean;
298
- codexImageEdits?: boolean;
299
- codexUsage?: boolean;
300
- codexFast?: boolean;
301
- grokImagineImage?: boolean;
302
- grokImagineVideo?: boolean;
303
- searchResults?: number;
304
- imageCount?: number;
305
- videoArtifactTtlMs?: number;
306
- } = {};
307
- const flags = input as Record<string, unknown>;
308
- assignFlag(patch, "codexSearch", flags["codexSearch"]);
309
- assignFlag(patch, "codexImages", flags["codexImages"]);
310
- assignFlag(patch, "codexImageEdits", flags["codexImageEdits"]);
311
- assignFlag(patch, "codexUsage", flags["codexUsage"]);
312
- assignFlag(patch, "codexFast", flags["codexFast"]);
313
- assignFlag(patch, "grokImagineImage", flags["grokImagineImage"]);
314
- assignFlag(patch, "grokImagineVideo", flags["grokImagineVideo"]);
315
- assignLimit(patch, "searchResults", flags["searchResults"]);
316
- assignLimit(patch, "imageCount", flags["imageCount"]);
317
- assignLimit(patch, "videoArtifactTtlMs", flags["videoArtifactTtlMs"]);
318
- return Object.freeze(patch);
319
- }
320
-
321
- /**
322
- * Strictly admit a caller-authored sparse section before normalizing it. Reads
323
- * remain compatibility-tolerant, but writes must never silently drop unknown
324
- * fields, coerce types, truncate decimals, or clamp out-of-range limits.
325
- */
326
- export function assertCapabilitySettingsPatch(
327
- input: unknown,
328
- label = "capability settings",
329
- ): asserts input is CapabilitySettingsPatch {
330
- assertPlainObject(input, label);
331
- for (const [key, value] of Object.entries(input)) {
332
- if (!KNOWN_KEYS.has(key)) throw new TypeError(`${label} contains unknown key ${key}`);
333
- if ((CAPABILITY_FLAG_KEYS as readonly string[]).includes(key)) {
334
- if (typeof value !== "boolean") throw new TypeError(`${label}.${key} must be a boolean`);
335
- continue;
336
- }
337
- const limitKey = key as CapabilityLimitKey;
338
- const bounds = CAPABILITY_SETTINGS_BOUNDS[limitKey];
339
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
340
- throw new TypeError(`${label}.${key} must be an integer`);
341
- }
342
- if (value < bounds.min || value > bounds.max) {
343
- throw new TypeError(`${label}.${key} must be in [${String(bounds.min)}, ${String(bounds.max)}]`);
344
- }
345
- }
346
- }
347
-
348
- /** Reject a resolved section the owner could not act on. Schema-valid by construction after normalize. */
349
- export function assertServiceableCapabilitySettings(value: CapabilitySettings): void {
350
- for (const key of CAPABILITY_FLAG_KEYS) {
351
- if (typeof value[key] !== "boolean") {
352
- throw new TypeError(`capability settings: ${key} must be a boolean`);
353
- }
354
- }
355
- for (const key of CAPABILITY_LIMIT_KEYS) {
356
- const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
357
- const numeric = value[key];
358
- if (!Number.isFinite(numeric) || !Number.isInteger(numeric) || numeric < bounds.min || numeric > bounds.max) {
359
- throw new TypeError(
360
- `capability settings: ${key} must be an integer in [${String(bounds.min)}, ${String(bounds.max)}]`,
361
- );
362
- }
363
- }
364
- }
365
-
366
- /**
367
- * Live capability-settings controller. Without an injected provider the
368
- * resolved state is the YAML/default layer and every write fails explicitly.
369
- */
370
- export class CapabilitySettingsController {
371
- readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
372
- private readonly settings: CapabilitySettingsService | undefined;
373
- private readonly base: CapabilitySettingsPatch;
374
- private readonly onListenerError: (error: unknown) => void;
375
- private readonly listeners = new Set<CapabilitySettingsListener>();
376
- private scope: CapabilitySettingsScope | undefined;
377
- private scopeDisposer: (() => void) | undefined;
378
- private localRevision = 0;
379
- private lastSnapshot: CapabilitySettingsSnapshot;
380
- private disposed = false;
381
-
382
- constructor(options: CapabilitySettingsControllerOptions = {}) {
383
- this.settings = options.settings;
384
- this.base = normalizeCapabilitySettingsPatch(options.base);
385
- this.onListenerError = options.onListenerError ?? (() => undefined);
386
- this.attachScope();
387
- this.lastSnapshot = this.readSnapshot();
388
- }
389
-
390
- /** Current revision-bearing snapshot. Re-reads the injected provider when present. */
391
- snapshot(): CapabilitySettingsSnapshot {
392
- const next = this.readSnapshot();
393
- this.lastSnapshot = next;
394
- return next;
395
- }
396
-
397
- /** Resolved capability section (schema defaults ← YAML base ← user). */
398
- current(): CapabilitySettings {
399
- return this.snapshot().value;
400
- }
401
-
402
- /**
403
- * Merge a secret-free patch into the user layer using compare-and-swap on
404
- * `expectedRevision` from a previously read {@link snapshot}.
405
- */
406
- async patch(patch: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
407
- return this.write("update", patch, expectedRevision);
408
- }
409
-
410
- /**
411
- * Replace the user section wholesale (`{}` re-inherits YAML base and defaults).
412
- * Compare-and-swap uses the same revision token as {@link patch}.
413
- */
414
- async replace(section: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
415
- return this.write("replace", section, expectedRevision);
416
- }
417
-
418
- /**
419
- * Observe committed snapshot changes. The disposer removes this listener;
420
- * an invocation already running still settles.
421
- */
422
- subscribe(listener: CapabilitySettingsListener): () => void {
423
- if (this.disposed) return () => undefined;
424
- this.listeners.add(listener);
425
- return () => {
426
- this.listeners.delete(listener);
427
- };
428
- }
429
-
430
- /**
431
- * Re-read the injected provider (or the local YAML/default layer) and notify
432
- * listeners when the secret-free snapshot moved.
433
- */
434
- reconcile(): CapabilitySettingsSnapshot {
435
- const next = this.readSnapshot();
436
- this.publish(next);
437
- return this.lastSnapshot;
438
- }
439
-
440
- /** Drop the register() watcher and every listener. Further writes fail. */
441
- dispose(): void {
442
- if (this.disposed) return;
443
- this.disposed = true;
444
- const releaseScope = this.scopeDisposer;
445
- this.scopeDisposer = undefined;
446
- this.scope = undefined;
447
- this.listeners.clear();
448
- try {
449
- releaseScope?.();
450
- } catch (error: unknown) {
451
- this.onListenerError(error);
452
- }
453
- }
454
-
455
- private attachScope(): void {
456
- const register = this.settings?.register;
457
- if (register === undefined) return;
458
- this.scope = register.call(this.settings, CAPABILITY_SETTINGS_NAMESPACE, CapabilitySettingsSchema, {
459
- base: this.base,
460
- applies: "live",
461
- validate: assertServiceableCapabilitySettings,
462
- });
463
- this.scopeDisposer = this.scope.watch(() => {
464
- if (this.disposed) return;
465
- this.reconcile();
466
- });
467
- }
468
-
469
- private writeReason(): "absent" | "read-only" | "disposed" | undefined {
470
- if (this.disposed) return "disposed";
471
- if (this.settings === undefined) return "absent";
472
- if (this.settings.writable === false) return "read-only";
473
- const canWrite =
474
- typeof this.settings.update === "function" ||
475
- typeof this.settings.replace === "function" ||
476
- this.scope !== undefined;
477
- if (!canWrite) return "read-only";
478
- return undefined;
479
- }
480
-
481
- private isWritable(): boolean {
482
- return this.writeReason() === undefined;
483
- }
484
-
485
- private async write(
486
- mode: "update" | "replace",
487
- input: CapabilitySettingsPatch,
488
- expectedRevision: number,
489
- ): Promise<CapabilitySettingsSnapshot> {
490
- const reason = this.writeReason();
491
- if (reason !== undefined) throw new CapabilitySettingsReadOnlyError(reason);
492
- assertCapabilitySettingsPatch(input, `capability settings ${mode}`);
493
- const current = this.readSnapshot();
494
- if (expectedRevision !== current.revision) {
495
- throw new CapabilitySettingsConflictError(expectedRevision, current.revision);
496
- }
497
- const normalized = normalizeCapabilitySettingsPatch(input);
498
- if (mode === "update" && !hasOwnKeys(normalized)) return current;
499
- const settings = this.settings!;
500
- try {
501
- if (mode === "update") {
502
- if (typeof settings.update === "function") {
503
- await settings.update(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
504
- } else {
505
- await this.scope!.update({ ...normalized });
506
- }
507
- } else if (typeof settings.replace === "function") {
508
- await settings.replace(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
509
- } else {
510
- await this.scope!.replace({ ...normalized });
511
- }
512
- } catch (error) {
513
- throw toConflictError(error) ?? error;
514
- }
515
- this.localRevision = current.revision + 1;
516
- const next = this.readSnapshot();
517
- this.publish(next);
518
- return this.lastSnapshot;
519
- }
520
-
521
- private readSnapshot(): CapabilitySettingsSnapshot {
522
- const writable = this.isWritable();
523
- const described = this.readDescribed();
524
- const base = described?.base !== undefined ? normalizeCapabilitySettingsPatch(described.base) : this.base;
525
- const user = described?.user !== undefined ? normalizeCapabilitySettingsPatch(described.user) : undefined;
526
- const resolved =
527
- described?.value !== undefined
528
- ? normalizeCapabilitySettings(described.value)
529
- : this.readResolvedFromService(base, user);
530
- const revision =
531
- typeof described?.revision === "number" && Number.isFinite(described.revision)
532
- ? described.revision
533
- : this.localRevision;
534
- return freezeSnapshot({
535
- ns: CAPABILITY_SETTINGS_NAMESPACE,
536
- value: resolved,
537
- revision,
538
- writable,
539
- applies: "live",
540
- secrets: [],
541
- ...(hasOwnKeys(base) ? { base } : {}),
542
- ...(user !== undefined && hasOwnKeys(user) ? { user } : {}),
543
- });
544
- }
545
-
546
- private readResolvedFromService(
547
- base: CapabilitySettingsPatch,
548
- user: CapabilitySettingsPatch | undefined,
549
- ): CapabilitySettings {
550
- const raw = this.scope?.get() ?? this.settings?.get?.(CAPABILITY_SETTINGS_NAMESPACE);
551
- if (raw !== undefined) return normalizeCapabilitySettings(raw);
552
- return resolveCapabilitySettings(base, user);
553
- }
554
-
555
- private readDescribed(): CapabilitySettingsDescriptor | undefined {
556
- const describe = this.settings?.describe;
557
- if (describe === undefined) return undefined;
558
- try {
559
- const descriptors = describe.call(this.settings, { redactSecrets: true });
560
- if (!Array.isArray(descriptors)) return undefined;
561
- return descriptors.find((entry) => entry?.ns === CAPABILITY_SETTINGS_NAMESPACE);
562
- } catch {
563
- return undefined;
564
- }
565
- }
566
-
567
- private publish(next: CapabilitySettingsSnapshot): void {
568
- if (sameSnapshot(this.lastSnapshot, next)) {
569
- this.lastSnapshot = next;
570
- return;
571
- }
572
- this.lastSnapshot = next;
573
- for (const listener of [...this.listeners]) {
574
- try {
575
- const result = listener(next);
576
- if (result !== undefined) void Promise.resolve(result).catch(this.onListenerError);
577
- } catch (error) {
578
- // One broken observer must not starve the rest or the write path.
579
- this.onListenerError(error);
580
- }
581
- }
582
- }
583
- }
584
-
585
- /** Construct a {@link CapabilitySettingsController}. */
586
- export function createCapabilitySettingsController(
587
- options: CapabilitySettingsControllerOptions = {},
588
- ): CapabilitySettingsController {
589
- return new CapabilitySettingsController(options);
590
- }
591
-
592
- function assignFlag<K extends CapabilityFlagKey>(target: { [P in K]?: boolean }, key: K, value: unknown): void {
593
- if (typeof value === "boolean") target[key] = value;
594
- }
595
-
596
- function assignLimit<K extends CapabilityLimitKey>(target: { [P in K]?: number }, key: K, value: unknown): void {
597
- if (typeof value !== "number" || !Number.isFinite(value)) return;
598
- const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
599
- const integer = Math.trunc(value);
600
- target[key] = Math.min(bounds.max, Math.max(bounds.min, integer));
601
- }
602
-
603
- function isPlainObject(value: unknown): value is Record<string, unknown> {
604
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
605
- const proto = Object.getPrototypeOf(value);
606
- return proto === Object.prototype || proto === null;
607
- }
608
-
609
- function assertPlainObject(value: unknown, label: string): asserts value is Record<string, unknown> {
610
- if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain object`);
611
- for (const key of Object.keys(value)) {
612
- if (RESERVED_KEYS.has(key) || SECRET_KEY.test(key)) {
613
- throw new TypeError(`${label} must be secret-free (rejected key ${key})`);
614
- }
615
- }
616
- }
617
-
618
- function hasOwnKeys(value: object): boolean {
619
- return Object.keys(value).length > 0;
620
- }
621
-
622
- function freezeSnapshot(snapshot: CapabilitySettingsSnapshot): CapabilitySettingsSnapshot {
623
- return Object.freeze(snapshot);
624
- }
625
-
626
- function sameSnapshot(left: CapabilitySettingsSnapshot | undefined, right: CapabilitySettingsSnapshot): boolean {
627
- if (left === undefined) return false;
628
- return (
629
- left.revision === right.revision &&
630
- left.writable === right.writable &&
631
- deepEqualJson(left.value, right.value) &&
632
- deepEqualJson(left.base, right.base) &&
633
- deepEqualJson(left.user, right.user)
634
- );
635
- }
636
-
637
- function deepEqualJson(a: unknown, b: unknown): boolean {
638
- if (a === b) return true;
639
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
640
- if (Array.isArray(a) || Array.isArray(b)) {
641
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
642
- return a.every((entry, index) => deepEqualJson(entry, b[index]));
643
- }
644
- const left = a as Record<string, unknown>;
645
- const right = b as Record<string, unknown>;
646
- const keys = Object.keys(left);
647
- if (keys.length !== Object.keys(right).length) return false;
648
- return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
649
- }
650
-
651
- function toConflictError(error: unknown): CapabilitySettingsConflictError | undefined {
652
- if (error instanceof CapabilitySettingsConflictError) return error;
653
- if (typeof error !== "object" || error === null) return undefined;
654
- const candidate = error as { code?: unknown; expected?: unknown; actual?: unknown };
655
- if (candidate.code !== "SETTINGS_CONFLICT") return undefined;
656
- if (typeof candidate.expected !== "number" || typeof candidate.actual !== "number") return undefined;
657
- return new CapabilitySettingsConflictError(candidate.expected, candidate.actual);
658
- }
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
3
+
4
+ /**
5
+ * Integration-ready capability settings controller for the
6
+ * `coding-subscription-oauth` namespace. Schema defaults sit under the
7
+ * composition/YAML `base`, and the user section layers on top. The controller
8
+ * talks to an injected structural settings service while registering a real
9
+ * Schemastery section that the Host settings service can render and validate.
10
+ * @module dsh-coding-subscription-oauth/capability-settings
11
+ */
12
+
13
+ /** Settings namespace owned by this plugin. */
14
+ export { CAPABILITY_SETTINGS_NAMESPACE } from "./ids.ts";
15
+
16
+ /** Default-off capability flags. Presence in the user section marks an override. */
17
+ export const CAPABILITY_FLAG_KEYS = [
18
+ "codexSearch",
19
+ "codexImages",
20
+ "codexImageEdits",
21
+ "codexImagesAnyModel",
22
+ "codexUsage",
23
+ "codexFast",
24
+ "grokImagineImage",
25
+ "grokImagineVideo",
26
+ ] as const;
27
+
28
+ /** Conservative numeric limits persisted beside the flags. */
29
+ export const CAPABILITY_LIMIT_KEYS = ["searchResults", "imageCount", "videoArtifactTtlMs"] as const;
30
+
31
+ /** Every key the controller admits into secret-free state. */
32
+ export const CAPABILITY_SETTINGS_KEYS = [...CAPABILITY_FLAG_KEYS, ...CAPABILITY_LIMIT_KEYS] as const;
33
+
34
+ export type CapabilityFlagKey = (typeof CAPABILITY_FLAG_KEYS)[number];
35
+ export type CapabilityLimitKey = (typeof CAPABILITY_LIMIT_KEYS)[number];
36
+ export type CapabilitySettingsKey = (typeof CAPABILITY_SETTINGS_KEYS)[number];
37
+
38
+ /** Resolved, secret-free capability section. */
39
+ export interface CapabilitySettings {
40
+ readonly codexSearch: boolean;
41
+ readonly codexImages: boolean;
42
+ readonly codexImageEdits: boolean;
43
+ /** Allow non-Codex-route models to use the Codex image generate/edit tools. */
44
+ readonly codexImagesAnyModel: boolean;
45
+ readonly codexUsage: boolean;
46
+ readonly codexFast: boolean;
47
+ readonly grokImagineImage: boolean;
48
+ readonly grokImagineVideo: boolean;
49
+ readonly searchResults: number;
50
+ readonly imageCount: number;
51
+ readonly videoArtifactTtlMs: number;
52
+ }
53
+
54
+ /** Sparse overlay used for YAML/composition `base` and the user section. */
55
+ export type CapabilitySettingsPatch = Partial<CapabilitySettings>;
56
+
57
+ /** Inclusive bounds and schema defaults for each numeric limit. */
58
+ export const CAPABILITY_SETTINGS_BOUNDS = {
59
+ searchResults: { min: 1, max: 20, default: 5 },
60
+ imageCount: { min: 1, max: 4, default: 1 },
61
+ videoArtifactTtlMs: {
62
+ min: 60 * 60 * 1000,
63
+ max: 7 * 24 * 60 * 60 * 1000,
64
+ default: 7 * 24 * 60 * 60 * 1000,
65
+ },
66
+ } as const;
67
+
68
+ /** Schema defaults: every flag off, every limit at its conservative default. */
69
+ export const DEFAULT_CAPABILITY_SETTINGS: CapabilitySettings = Object.freeze({
70
+ codexSearch: false,
71
+ codexImages: false,
72
+ codexImageEdits: false,
73
+ codexImagesAnyModel: false,
74
+ codexUsage: false,
75
+ codexFast: false,
76
+ grokImagineImage: false,
77
+ grokImagineVideo: false,
78
+ searchResults: CAPABILITY_SETTINGS_BOUNDS.searchResults.default,
79
+ imageCount: CAPABILITY_SETTINGS_BOUNDS.imageCount.default,
80
+ videoArtifactTtlMs: CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default,
81
+ });
82
+
83
+ /**
84
+ * Real Schemastery schema registered with the Host settings service. Defaults
85
+ * remain conservative, and bounds are enforced before a user document commits.
86
+ */
87
+ export const CapabilitySettingsSchema = Schema.object({
88
+ codexSearch: Schema.boolean().default(false),
89
+ codexImages: Schema.boolean().default(false),
90
+ codexImageEdits: Schema.boolean().default(false),
91
+ codexImagesAnyModel: Schema.boolean().default(false),
92
+ codexUsage: Schema.boolean().default(false),
93
+ codexFast: Schema.boolean().default(false),
94
+ grokImagineImage: Schema.boolean().default(false),
95
+ grokImagineVideo: Schema.boolean().default(false),
96
+ searchResults: Schema.number()
97
+ .step(1)
98
+ .min(CAPABILITY_SETTINGS_BOUNDS.searchResults.min)
99
+ .max(CAPABILITY_SETTINGS_BOUNDS.searchResults.max)
100
+ .default(CAPABILITY_SETTINGS_BOUNDS.searchResults.default),
101
+ imageCount: Schema.number()
102
+ .step(1)
103
+ .min(CAPABILITY_SETTINGS_BOUNDS.imageCount.min)
104
+ .max(CAPABILITY_SETTINGS_BOUNDS.imageCount.max)
105
+ .default(CAPABILITY_SETTINGS_BOUNDS.imageCount.default),
106
+ videoArtifactTtlMs: Schema.number()
107
+ .step(1)
108
+ .min(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.min)
109
+ .max(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.max)
110
+ .default(CAPABILITY_SETTINGS_BOUNDS.videoArtifactTtlMs.default),
111
+ });
112
+
113
+ /** Serialized schema metadata consumed by Settings UI tests and diagnostics. */
114
+ export const CAPABILITY_SETTINGS_SCHEMA_JSON = CapabilitySettingsSchema.toJSON();
115
+
116
+ export type CapabilitySettingsSchemaType = typeof CapabilitySettingsSchema;
117
+
118
+ /** Revision-bearing, secret-free snapshot used for CAS writes and UI. */
119
+ export interface CapabilitySettingsSnapshot {
120
+ readonly ns: typeof CAPABILITY_SETTINGS_NAMESPACE;
121
+ readonly value: CapabilitySettings;
122
+ readonly base?: CapabilitySettingsPatch;
123
+ readonly user?: CapabilitySettingsPatch;
124
+ readonly revision: number;
125
+ readonly writable: boolean;
126
+ readonly applies: "live";
127
+ readonly secrets: readonly [];
128
+ }
129
+
130
+ /** Owner-facing subset of `ctx.settings.register()` used when the parent injects a provider. */
131
+ export interface CapabilitySettingsScope {
132
+ get(): unknown;
133
+ watch(callback: (next: unknown, prev: unknown) => void | Promise<void>): () => void;
134
+ update(patch: object): Promise<void>;
135
+ replace(section: object): Promise<void>;
136
+ }
137
+
138
+ /** One namespace descriptor as returned by a structural `describe()`. */
139
+ export interface CapabilitySettingsDescriptor {
140
+ readonly ns: string;
141
+ readonly value?: unknown;
142
+ readonly base?: unknown;
143
+ readonly user?: unknown;
144
+ readonly revision?: number;
145
+ readonly applies?: "live" | "restart";
146
+ readonly secrets?: readonly { readonly path?: readonly string[]; readonly set?: boolean }[];
147
+ }
148
+
149
+ /**
150
+ * Duck-typed settings service. A real `ctx.settings` satisfies this without a
151
+ * compile-time dependency on `@deepseek-ai/dsh-settings`.
152
+ */
153
+ export interface CapabilitySettingsService {
154
+ readonly writable?: boolean;
155
+ describe?(options?: { readonly redactSecrets?: boolean }): readonly CapabilitySettingsDescriptor[];
156
+ get?(ns: string): unknown;
157
+ update?(ns: string, patch: object, expectedRevision?: number): Promise<void>;
158
+ replace?(ns: string, section: object, expectedRevision?: number): Promise<void>;
159
+ register?(
160
+ ns: string,
161
+ schema: CapabilitySettingsSchemaType,
162
+ options?: {
163
+ readonly base?: CapabilitySettingsPatch;
164
+ readonly applies?: "live" | "restart";
165
+ readonly validate?: (value: CapabilitySettings) => void;
166
+ },
167
+ ): CapabilitySettingsScope;
168
+ }
169
+
170
+ /** Construction options. `base` is the YAML / composition entry layered under the user section. */
171
+ export interface CapabilitySettingsControllerOptions {
172
+ readonly settings?: CapabilitySettingsService | undefined;
173
+ readonly base?: CapabilitySettingsPatch | undefined;
174
+ /** Contain both synchronous and asynchronous observer failures. */
175
+ readonly onListenerError?: ((error: unknown) => void) | undefined;
176
+ }
177
+
178
+ /** Listener invoked after a committed snapshot change. */
179
+ export type CapabilitySettingsListener = (snapshot: CapabilitySettingsSnapshot) => void | Promise<void>;
180
+
181
+ const SECRET_KEY = /secret|token|password|passphrase|apikey|api_key|authorization|credential|cookie|private[_-]?key/iu;
182
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
183
+ const KNOWN_KEYS = new Set<string>(CAPABILITY_SETTINGS_KEYS);
184
+
185
+ /**
186
+ * A write refused because the namespace moved since the caller read it.
187
+ * `code` matches the Host settings seam so a later wire layer can map it.
188
+ */
189
+ export class CapabilitySettingsConflictError extends Error {
190
+ readonly code = "SETTINGS_CONFLICT";
191
+ readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
192
+ readonly expected: number;
193
+ readonly actual: number;
194
+
195
+ constructor(expected: number, actual: number) {
196
+ super(
197
+ `settings namespace "${CAPABILITY_SETTINGS_NAMESPACE}" changed since it was read ` +
198
+ `(expected revision ${String(expected)}, now ${String(actual)})`,
199
+ );
200
+ this.name = "CapabilitySettingsConflictError";
201
+ this.expected = expected;
202
+ this.actual = actual;
203
+ }
204
+ }
205
+
206
+ /** A write refused because no writable settings provider is attached. */
207
+ export class CapabilitySettingsReadOnlyError extends Error {
208
+ readonly code: "SETTINGS_PROVIDER_ABSENT" | "SETTINGS_READ_ONLY" | "SETTINGS_DISPOSED";
209
+ readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
210
+ readonly reason: "absent" | "read-only" | "disposed";
211
+
212
+ constructor(reason: "absent" | "read-only" | "disposed") {
213
+ const code =
214
+ reason === "absent"
215
+ ? "SETTINGS_PROVIDER_ABSENT"
216
+ : reason === "disposed"
217
+ ? "SETTINGS_DISPOSED"
218
+ : "SETTINGS_READ_ONLY";
219
+ const detail =
220
+ reason === "absent"
221
+ ? `settings provider is absent: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
222
+ : reason === "disposed"
223
+ ? `settings controller is disposed: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated`
224
+ : `settings provider is read-only: "${CAPABILITY_SETTINGS_NAMESPACE}" cannot be updated in-process`;
225
+ super(detail);
226
+ this.name = "CapabilitySettingsReadOnlyError";
227
+ this.code = code;
228
+ this.reason = reason;
229
+ }
230
+ }
231
+
232
+ export function isCapabilitySettingsConflictError(error: unknown): error is CapabilitySettingsConflictError {
233
+ return error instanceof CapabilitySettingsConflictError;
234
+ }
235
+
236
+ export function isCapabilitySettingsReadOnlyError(error: unknown): error is CapabilitySettingsReadOnlyError {
237
+ return error instanceof CapabilitySettingsReadOnlyError;
238
+ }
239
+
240
+ /** Pick every independently default-off flag from a resolved section. */
241
+ export function capabilityFlags(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityFlagKey> {
242
+ return {
243
+ codexSearch: settings.codexSearch,
244
+ codexImages: settings.codexImages,
245
+ codexImageEdits: settings.codexImageEdits,
246
+ codexImagesAnyModel: settings.codexImagesAnyModel,
247
+ codexUsage: settings.codexUsage,
248
+ codexFast: settings.codexFast,
249
+ grokImagineImage: settings.grokImagineImage,
250
+ grokImagineVideo: settings.grokImagineVideo,
251
+ };
252
+ }
253
+
254
+ /** Pick the conservative numeric limits from a resolved section. */
255
+ export function capabilityLimits(settings: CapabilitySettings): Pick<CapabilitySettings, CapabilityLimitKey> {
256
+ return {
257
+ searchResults: settings.searchResults,
258
+ imageCount: settings.imageCount,
259
+ videoArtifactTtlMs: settings.videoArtifactTtlMs,
260
+ };
261
+ }
262
+
263
+ /** Layer schema defaults, then YAML/composition `base`, then the user section. */
264
+ export function resolveCapabilitySettings(
265
+ base?: CapabilitySettingsPatch | undefined,
266
+ user?: CapabilitySettingsPatch | undefined,
267
+ ): CapabilitySettings {
268
+ return normalizeCapabilitySettings({
269
+ ...DEFAULT_CAPABILITY_SETTINGS,
270
+ ...normalizeCapabilitySettingsPatch(base),
271
+ ...normalizeCapabilitySettingsPatch(user),
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Admit a candidate section: known keys only, flags default off, limits clamped,
277
+ * secret-shaped keys dropped. Used for both reads and the structural schema.
278
+ */
279
+ export function normalizeCapabilitySettings(input?: unknown): CapabilitySettings {
280
+ const patch = normalizeCapabilitySettingsPatch(input);
281
+ const codexImages = patch.codexImages ?? DEFAULT_CAPABILITY_SETTINGS.codexImages;
282
+ return Object.freeze({
283
+ codexSearch: patch.codexSearch ?? DEFAULT_CAPABILITY_SETTINGS.codexSearch,
284
+ codexImages,
285
+ codexImageEdits: patch.codexImageEdits ?? DEFAULT_CAPABILITY_SETTINGS.codexImageEdits,
286
+ // any-model is meaningless without images; clear at admit so UI cascade alone cannot leave it on
287
+ codexImagesAnyModel: codexImages
288
+ ? (patch.codexImagesAnyModel ?? DEFAULT_CAPABILITY_SETTINGS.codexImagesAnyModel)
289
+ : false,
290
+ codexUsage: patch.codexUsage ?? DEFAULT_CAPABILITY_SETTINGS.codexUsage,
291
+ codexFast: patch.codexFast ?? DEFAULT_CAPABILITY_SETTINGS.codexFast,
292
+ grokImagineImage: patch.grokImagineImage ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineImage,
293
+ grokImagineVideo: patch.grokImagineVideo ?? DEFAULT_CAPABILITY_SETTINGS.grokImagineVideo,
294
+ searchResults: patch.searchResults ?? DEFAULT_CAPABILITY_SETTINGS.searchResults,
295
+ imageCount: patch.imageCount ?? DEFAULT_CAPABILITY_SETTINGS.imageCount,
296
+ videoArtifactTtlMs: patch.videoArtifactTtlMs ?? DEFAULT_CAPABILITY_SETTINGS.videoArtifactTtlMs,
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Normalize a sparse overlay. Invalid or secret fields are omitted so a lower
302
+ * layer (YAML base / schema default) remains authoritative for that key.
303
+ */
304
+ export function normalizeCapabilitySettingsPatch(input?: unknown): CapabilitySettingsPatch {
305
+ if (!isPlainObject(input)) return {};
306
+ const patch: {
307
+ codexSearch?: boolean;
308
+ codexImages?: boolean;
309
+ codexImageEdits?: boolean;
310
+ codexImagesAnyModel?: boolean;
311
+ codexUsage?: boolean;
312
+ codexFast?: boolean;
313
+ grokImagineImage?: boolean;
314
+ grokImagineVideo?: boolean;
315
+ searchResults?: number;
316
+ imageCount?: number;
317
+ videoArtifactTtlMs?: number;
318
+ } = {};
319
+ const flags = input as Record<string, unknown>;
320
+ assignFlag(patch, "codexSearch", flags["codexSearch"]);
321
+ assignFlag(patch, "codexImages", flags["codexImages"]);
322
+ assignFlag(patch, "codexImageEdits", flags["codexImageEdits"]);
323
+ assignFlag(patch, "codexImagesAnyModel", flags["codexImagesAnyModel"]);
324
+ assignFlag(patch, "codexUsage", flags["codexUsage"]);
325
+ assignFlag(patch, "codexFast", flags["codexFast"]);
326
+ assignFlag(patch, "grokImagineImage", flags["grokImagineImage"]);
327
+ assignFlag(patch, "grokImagineVideo", flags["grokImagineVideo"]);
328
+ assignLimit(patch, "searchResults", flags["searchResults"]);
329
+ assignLimit(patch, "imageCount", flags["imageCount"]);
330
+ assignLimit(patch, "videoArtifactTtlMs", flags["videoArtifactTtlMs"]);
331
+ return Object.freeze(patch);
332
+ }
333
+
334
+ /**
335
+ * Strictly admit a caller-authored sparse section before normalizing it. Reads
336
+ * remain compatibility-tolerant, but writes must never silently drop unknown
337
+ * fields, coerce types, truncate decimals, or clamp out-of-range limits.
338
+ */
339
+ export function assertCapabilitySettingsPatch(
340
+ input: unknown,
341
+ label = "capability settings",
342
+ ): asserts input is CapabilitySettingsPatch {
343
+ assertPlainObject(input, label);
344
+ for (const [key, value] of Object.entries(input)) {
345
+ if (!KNOWN_KEYS.has(key)) throw new TypeError(`${label} contains unknown key ${key}`);
346
+ if ((CAPABILITY_FLAG_KEYS as readonly string[]).includes(key)) {
347
+ if (typeof value !== "boolean") throw new TypeError(`${label}.${key} must be a boolean`);
348
+ continue;
349
+ }
350
+ const limitKey = key as CapabilityLimitKey;
351
+ const bounds = CAPABILITY_SETTINGS_BOUNDS[limitKey];
352
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
353
+ throw new TypeError(`${label}.${key} must be an integer`);
354
+ }
355
+ if (value < bounds.min || value > bounds.max) {
356
+ throw new TypeError(`${label}.${key} must be in [${String(bounds.min)}, ${String(bounds.max)}]`);
357
+ }
358
+ }
359
+ }
360
+
361
+ /** Reject a resolved section the owner could not act on. Schema-valid by construction after normalize. */
362
+ export function assertServiceableCapabilitySettings(value: CapabilitySettings): void {
363
+ for (const key of CAPABILITY_FLAG_KEYS) {
364
+ if (typeof value[key] !== "boolean") {
365
+ throw new TypeError(`capability settings: ${key} must be a boolean`);
366
+ }
367
+ }
368
+ for (const key of CAPABILITY_LIMIT_KEYS) {
369
+ const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
370
+ const numeric = value[key];
371
+ if (!Number.isFinite(numeric) || !Number.isInteger(numeric) || numeric < bounds.min || numeric > bounds.max) {
372
+ throw new TypeError(
373
+ `capability settings: ${key} must be an integer in [${String(bounds.min)}, ${String(bounds.max)}]`,
374
+ );
375
+ }
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Live capability-settings controller. Without an injected provider the
381
+ * resolved state is the YAML/default layer and every write fails explicitly.
382
+ */
383
+ export class CapabilitySettingsController {
384
+ readonly ns = CAPABILITY_SETTINGS_NAMESPACE;
385
+ private readonly settings: CapabilitySettingsService | undefined;
386
+ private readonly base: CapabilitySettingsPatch;
387
+ private readonly onListenerError: (error: unknown) => void;
388
+ private readonly listeners = new Set<CapabilitySettingsListener>();
389
+ private scope: CapabilitySettingsScope | undefined;
390
+ private scopeDisposer: (() => void) | undefined;
391
+ private localRevision = 0;
392
+ private lastSnapshot: CapabilitySettingsSnapshot;
393
+ private disposed = false;
394
+
395
+ constructor(options: CapabilitySettingsControllerOptions = {}) {
396
+ this.settings = options.settings;
397
+ this.base = normalizeCapabilitySettingsPatch(options.base);
398
+ this.onListenerError = options.onListenerError ?? (() => undefined);
399
+ this.attachScope();
400
+ this.lastSnapshot = this.readSnapshot();
401
+ }
402
+
403
+ /** Current revision-bearing snapshot. Re-reads the injected provider when present. */
404
+ snapshot(): CapabilitySettingsSnapshot {
405
+ const next = this.readSnapshot();
406
+ this.lastSnapshot = next;
407
+ return next;
408
+ }
409
+
410
+ /** Resolved capability section (schema defaults ← YAML base ← user). */
411
+ current(): CapabilitySettings {
412
+ return this.snapshot().value;
413
+ }
414
+
415
+ /**
416
+ * Merge a secret-free patch into the user layer using compare-and-swap on
417
+ * `expectedRevision` from a previously read {@link snapshot}.
418
+ */
419
+ async patch(patch: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
420
+ return this.write("update", patch, expectedRevision);
421
+ }
422
+
423
+ /**
424
+ * Replace the user section wholesale (`{}` re-inherits YAML base and defaults).
425
+ * Compare-and-swap uses the same revision token as {@link patch}.
426
+ */
427
+ async replace(section: CapabilitySettingsPatch, expectedRevision: number): Promise<CapabilitySettingsSnapshot> {
428
+ return this.write("replace", section, expectedRevision);
429
+ }
430
+
431
+ /**
432
+ * Observe committed snapshot changes. The disposer removes this listener;
433
+ * an invocation already running still settles.
434
+ */
435
+ subscribe(listener: CapabilitySettingsListener): () => void {
436
+ if (this.disposed) return () => undefined;
437
+ this.listeners.add(listener);
438
+ return () => {
439
+ this.listeners.delete(listener);
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Re-read the injected provider (or the local YAML/default layer) and notify
445
+ * listeners when the secret-free snapshot moved.
446
+ */
447
+ reconcile(): CapabilitySettingsSnapshot {
448
+ const next = this.readSnapshot();
449
+ this.publish(next);
450
+ return this.lastSnapshot;
451
+ }
452
+
453
+ /** Drop the register() watcher and every listener. Further writes fail. */
454
+ dispose(): void {
455
+ if (this.disposed) return;
456
+ this.disposed = true;
457
+ const releaseScope = this.scopeDisposer;
458
+ this.scopeDisposer = undefined;
459
+ this.scope = undefined;
460
+ this.listeners.clear();
461
+ try {
462
+ releaseScope?.();
463
+ } catch (error: unknown) {
464
+ this.onListenerError(error);
465
+ }
466
+ }
467
+
468
+ private attachScope(): void {
469
+ const register = this.settings?.register;
470
+ if (register === undefined) return;
471
+ this.scope = register.call(this.settings, CAPABILITY_SETTINGS_NAMESPACE, CapabilitySettingsSchema, {
472
+ base: this.base,
473
+ applies: "live",
474
+ validate: assertServiceableCapabilitySettings,
475
+ });
476
+ this.scopeDisposer = this.scope.watch(() => {
477
+ if (this.disposed) return;
478
+ this.reconcile();
479
+ });
480
+ }
481
+
482
+ private writeReason(): "absent" | "read-only" | "disposed" | undefined {
483
+ if (this.disposed) return "disposed";
484
+ if (this.settings === undefined) return "absent";
485
+ if (this.settings.writable === false) return "read-only";
486
+ const canWrite =
487
+ typeof this.settings.update === "function" ||
488
+ typeof this.settings.replace === "function" ||
489
+ this.scope !== undefined;
490
+ if (!canWrite) return "read-only";
491
+ return undefined;
492
+ }
493
+
494
+ private isWritable(): boolean {
495
+ return this.writeReason() === undefined;
496
+ }
497
+
498
+ private async write(
499
+ mode: "update" | "replace",
500
+ input: CapabilitySettingsPatch,
501
+ expectedRevision: number,
502
+ ): Promise<CapabilitySettingsSnapshot> {
503
+ const reason = this.writeReason();
504
+ if (reason !== undefined) throw new CapabilitySettingsReadOnlyError(reason);
505
+ assertCapabilitySettingsPatch(input, `capability settings ${mode}`);
506
+ const current = this.readSnapshot();
507
+ if (expectedRevision !== current.revision) {
508
+ throw new CapabilitySettingsConflictError(expectedRevision, current.revision);
509
+ }
510
+ const normalized = normalizeCapabilitySettingsPatch(input);
511
+ if (mode === "update" && !hasOwnKeys(normalized)) return current;
512
+ const settings = this.settings!;
513
+ try {
514
+ if (mode === "update") {
515
+ if (typeof settings.update === "function") {
516
+ await settings.update(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
517
+ } else {
518
+ await this.scope!.update({ ...normalized });
519
+ }
520
+ } else if (typeof settings.replace === "function") {
521
+ await settings.replace(CAPABILITY_SETTINGS_NAMESPACE, { ...normalized }, expectedRevision);
522
+ } else {
523
+ await this.scope!.replace({ ...normalized });
524
+ }
525
+ } catch (error) {
526
+ throw toConflictError(error) ?? error;
527
+ }
528
+ this.localRevision = current.revision + 1;
529
+ const next = this.readSnapshot();
530
+ this.publish(next);
531
+ return this.lastSnapshot;
532
+ }
533
+
534
+ private readSnapshot(): CapabilitySettingsSnapshot {
535
+ const writable = this.isWritable();
536
+ const described = this.readDescribed();
537
+ const base = described?.base !== undefined ? normalizeCapabilitySettingsPatch(described.base) : this.base;
538
+ const user = described?.user !== undefined ? normalizeCapabilitySettingsPatch(described.user) : undefined;
539
+ const resolved =
540
+ described?.value !== undefined
541
+ ? normalizeCapabilitySettings(described.value)
542
+ : this.readResolvedFromService(base, user);
543
+ const revision =
544
+ typeof described?.revision === "number" && Number.isFinite(described.revision)
545
+ ? described.revision
546
+ : this.localRevision;
547
+ return freezeSnapshot({
548
+ ns: CAPABILITY_SETTINGS_NAMESPACE,
549
+ value: resolved,
550
+ revision,
551
+ writable,
552
+ applies: "live",
553
+ secrets: [],
554
+ ...(hasOwnKeys(base) ? { base } : {}),
555
+ ...(user !== undefined && hasOwnKeys(user) ? { user } : {}),
556
+ });
557
+ }
558
+
559
+ private readResolvedFromService(
560
+ base: CapabilitySettingsPatch,
561
+ user: CapabilitySettingsPatch | undefined,
562
+ ): CapabilitySettings {
563
+ const raw = this.scope?.get() ?? this.settings?.get?.(CAPABILITY_SETTINGS_NAMESPACE);
564
+ if (raw !== undefined) return normalizeCapabilitySettings(raw);
565
+ return resolveCapabilitySettings(base, user);
566
+ }
567
+
568
+ private readDescribed(): CapabilitySettingsDescriptor | undefined {
569
+ const describe = this.settings?.describe;
570
+ if (describe === undefined) return undefined;
571
+ try {
572
+ const descriptors = describe.call(this.settings, { redactSecrets: true });
573
+ if (!Array.isArray(descriptors)) return undefined;
574
+ return descriptors.find((entry) => entry?.ns === CAPABILITY_SETTINGS_NAMESPACE);
575
+ } catch {
576
+ return undefined;
577
+ }
578
+ }
579
+
580
+ private publish(next: CapabilitySettingsSnapshot): void {
581
+ if (sameSnapshot(this.lastSnapshot, next)) {
582
+ this.lastSnapshot = next;
583
+ return;
584
+ }
585
+ this.lastSnapshot = next;
586
+ for (const listener of [...this.listeners]) {
587
+ try {
588
+ const result = listener(next);
589
+ if (result !== undefined) void Promise.resolve(result).catch(this.onListenerError);
590
+ } catch (error) {
591
+ // One broken observer must not starve the rest or the write path.
592
+ this.onListenerError(error);
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ /** Construct a {@link CapabilitySettingsController}. */
599
+ export function createCapabilitySettingsController(
600
+ options: CapabilitySettingsControllerOptions = {},
601
+ ): CapabilitySettingsController {
602
+ return new CapabilitySettingsController(options);
603
+ }
604
+
605
+ function assignFlag<K extends CapabilityFlagKey>(target: { [P in K]?: boolean }, key: K, value: unknown): void {
606
+ if (typeof value === "boolean") target[key] = value;
607
+ }
608
+
609
+ function assignLimit<K extends CapabilityLimitKey>(target: { [P in K]?: number }, key: K, value: unknown): void {
610
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
611
+ const bounds = CAPABILITY_SETTINGS_BOUNDS[key];
612
+ const integer = Math.trunc(value);
613
+ target[key] = Math.min(bounds.max, Math.max(bounds.min, integer));
614
+ }
615
+
616
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
617
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
618
+ const proto = Object.getPrototypeOf(value);
619
+ return proto === Object.prototype || proto === null;
620
+ }
621
+
622
+ function assertPlainObject(value: unknown, label: string): asserts value is Record<string, unknown> {
623
+ if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain object`);
624
+ for (const key of Object.keys(value)) {
625
+ if (RESERVED_KEYS.has(key) || SECRET_KEY.test(key)) {
626
+ throw new TypeError(`${label} must be secret-free (rejected key ${key})`);
627
+ }
628
+ }
629
+ }
630
+
631
+ function hasOwnKeys(value: object): boolean {
632
+ return Object.keys(value).length > 0;
633
+ }
634
+
635
+ function freezeSnapshot(snapshot: CapabilitySettingsSnapshot): CapabilitySettingsSnapshot {
636
+ return Object.freeze(snapshot);
637
+ }
638
+
639
+ function sameSnapshot(left: CapabilitySettingsSnapshot | undefined, right: CapabilitySettingsSnapshot): boolean {
640
+ if (left === undefined) return false;
641
+ return (
642
+ left.revision === right.revision &&
643
+ left.writable === right.writable &&
644
+ deepEqualJson(left.value, right.value) &&
645
+ deepEqualJson(left.base, right.base) &&
646
+ deepEqualJson(left.user, right.user)
647
+ );
648
+ }
649
+
650
+ function deepEqualJson(a: unknown, b: unknown): boolean {
651
+ if (a === b) return true;
652
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
653
+ if (Array.isArray(a) || Array.isArray(b)) {
654
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
655
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
656
+ }
657
+ const left = a as Record<string, unknown>;
658
+ const right = b as Record<string, unknown>;
659
+ const keys = Object.keys(left);
660
+ if (keys.length !== Object.keys(right).length) return false;
661
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
662
+ }
663
+
664
+ function toConflictError(error: unknown): CapabilitySettingsConflictError | undefined {
665
+ if (error instanceof CapabilitySettingsConflictError) return error;
666
+ if (typeof error !== "object" || error === null) return undefined;
667
+ const candidate = error as { code?: unknown; expected?: unknown; actual?: unknown };
668
+ if (candidate.code !== "SETTINGS_CONFLICT") return undefined;
669
+ if (typeof candidate.expected !== "number" || typeof candidate.actual !== "number") return undefined;
670
+ return new CapabilitySettingsConflictError(candidate.expected, candidate.actual);
671
+ }