index.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * Host Remote owner for the configuration surfaces over the settings-domain
  3. * seams. Two namespaces: `settings`, the redacted reads and writes of
  4. * `ctx.settings`, owned by the class below; and `credentials`, mounted from
  5. * here as its own plugin.
  6. *
  7. * @module @deepseek-ai/dsh-api-settings-controller
  8. */
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
  11. import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
  12. import type {
  13. SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
  14. } from '@deepseek-ai/dsh-settings/types'
  15. import type { JsonValue } from '@deepseek-ai/dsh-session/types'
  16. import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  17. import { z } from 'zod'
  18. import { CredentialsController } from './credentials.ts'
  19. export { CredentialsController } from './credentials.ts'
  20. export type * from './types.ts'
  21. const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
  22. /**
  23. * Project one redacted descriptor onto its wire view, field by field. The
  24. * Gateway returns a business result without decoding it, so a provider whose
  25. * descriptor carried extra enumerable properties would otherwise serialize them
  26. * to the caller.
  27. * @param descriptor - one descriptor read under `redactSecrets`.
  28. * @returns the same facts with nothing else attached.
  29. */
  30. function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
  31. return {
  32. ns: String(descriptor.ns),
  33. schema: descriptor.schema as JsonValue,
  34. value: descriptor.value as JsonValue,
  35. ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue },
  36. ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue },
  37. applies: descriptor.applies,
  38. secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
  39. revision: descriptor.revision,
  40. }
  41. }
  42. declare module '@deepseek-ai/cordis' {
  43. interface Context {
  44. /** Host owner of the `settings` Remote namespace. */
  45. settingsController: SettingsController
  46. }
  47. }
  48. /**
  49. * Host service backing the generated `ctx.remote.settings` namespace. Every
  50. * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot
  51. * ride a response. Writes expose the settings service's merge, replacement,
  52. * and path-addressed operations, and classify every provider refusal as
  53. * `settings-conflict` or `settings-rejected` with the service's message.
  54. */
  55. export class SettingsController extends TypertRemoteService {
  56. /**
  57. * Register the settings namespace and mount the credentials namespace beside
  58. * it. Both namespaces stay registered when a provider is absent so calls can
  59. * return the configuration API's actionable missing-provider diagnostic.
  60. * @param ctx - Host context where settings and credential providers may be mounted.
  61. */
  62. constructor(ctx: Context) {
  63. super(ctx, 'settingsController', { namespace: 'settings' })
  64. ctx.plugin(CredentialsController)
  65. }
  66. /**
  67. * Describe every registered namespace for a configuration page: redacted
  68. * layered values plus the serialized schema the page renders its form from.
  69. * @returns provider writability, local-document presence, and one view per namespace.
  70. * @throws TypertRemoteFailure when no settings provider is mounted.
  71. */
  72. @Remote
  73. describe(): SettingsDescribeValue {
  74. const settings = this.provider()
  75. return {
  76. writable: settings.writable,
  77. hasDocument: settings.documentPath !== undefined,
  78. namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
  79. }
  80. }
  81. /**
  82. * Merge a patch into one namespace's stored user section.
  83. * @param ns - namespace key to write.
  84. * @param patch - fields to merge into the user section.
  85. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
  86. * @returns the namespace's redacted view after the write.
  87. * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
  88. */
  89. @Remote
  90. update(
  91. ns: string,
  92. patch: Record<string, JsonValue>,
  93. expectedRevision: number | undefined,
  94. ): Promise<SettingsNamespaceView> {
  95. return this.write(ns, 'update', patch, expectedRevision)
  96. }
  97. /**
  98. * Replace one namespace's stored user section wholesale.
  99. * @param ns - namespace key to write.
  100. * @param section - complete replacement user section.
  101. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
  102. * @returns the namespace's redacted view after the write.
  103. * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
  104. */
  105. @Remote
  106. replace(
  107. ns: string,
  108. section: Record<string, JsonValue>,
  109. expectedRevision: number | undefined,
  110. ): Promise<SettingsNamespaceView> {
  111. return this.write(ns, 'replace', section, expectedRevision)
  112. }
  113. /**
  114. * Apply path-addressed edits to one namespace's user section, resolved against
  115. * the section as stored rather than against whatever the caller last read,
  116. * then answer with that namespace's new redacted view.
  117. * @param ns - namespace key to write.
  118. * @param ops - the edits to apply, in order.
  119. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
  120. * @returns the namespace's redacted view after the write.
  121. * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
  122. */
  123. @Remote
  124. async mutate(
  125. ns: string,
  126. ops: SettingsPathOpView[],
  127. expectedRevision: number | undefined,
  128. ): Promise<SettingsNamespaceView> {
  129. return this.write(ns, 'mutate', ops, expectedRevision)
  130. }
  131. private async write(
  132. ns: string,
  133. mode: 'update' | 'replace' | 'mutate',
  134. input: Record<string, JsonValue> | SettingsPathOpView[],
  135. expectedRevision: number | undefined,
  136. ): Promise<SettingsNamespaceView> {
  137. const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
  138. if (!parsed.success) {
  139. throw new TypertRemoteFailure({
  140. code: 'bad-request',
  141. message: `invalid payload for settings.${mode}`,
  142. details: { issues: parsed.error.issues },
  143. })
  144. }
  145. const settings = this.provider()
  146. let branded
  147. try {
  148. // A malformed name can address no registration, so it fails exactly as an
  149. // unregistered one does.
  150. branded = settingsNamespace(parsed.data.ns)
  151. } catch (error: unknown) {
  152. throw rejected(ns, error)
  153. }
  154. try {
  155. if (mode === 'update') await settings.update(branded, input, expectedRevision)
  156. else if (mode === 'replace') await settings.replace(branded, input, expectedRevision)
  157. else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision)
  158. } catch (error: unknown) {
  159. throw rejected(ns, error)
  160. }
  161. const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
  162. if (descriptor === undefined) {
  163. // The write committed but the namespace vanished before this read: only a
  164. // concurrent registrant disposal can produce it.
  165. throw new TypertRemoteFailure({
  166. code: 'internal',
  167. message: `settings namespace "${ns}" was disposed after the ${mode}`,
  168. details: {},
  169. })
  170. }
  171. return namespaceView(descriptor)
  172. }
  173. /** Resolve the optional provider or report how to supply it. */
  174. private provider(): SettingsProvider {
  175. const settings = this.ctx.get('settings')
  176. if (settings === undefined) {
  177. throw new TypertRemoteFailure({
  178. code: 'internal',
  179. message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
  180. details: {},
  181. })
  182. }
  183. return settings
  184. }
  185. }
  186. /**
  187. * Classify one seam refusal. A stale writer is its own outcome, not a malformed
  188. * request: the client must re-read and re-apply rather than treat the write as
  189. * invalid.
  190. * @param ns - the namespace the write addressed.
  191. * @param error - whatever the seam threw.
  192. * @returns the failure to raise for that refusal.
  193. */
  194. function rejected(ns: string, error: unknown): TypertRemoteFailure {
  195. if (error instanceof SettingsConflictError) {
  196. return new TypertRemoteFailure({
  197. code: 'settings-conflict',
  198. message: error.message,
  199. details: { ns, expected: error.expected, actual: error.actual },
  200. })
  201. }
  202. return new TypertRemoteFailure({
  203. code: 'settings-rejected',
  204. message: error instanceof Error ? error.message : String(error),
  205. details: { ns },
  206. })
  207. }
  208. export default SettingsController