credentials.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. /**
  2. * Host owner of the `credentials` Remote namespace: the reference half of
  3. * `ctx.credentials` as a browser configuration page reads and writes it.
  4. *
  5. * @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts
  6. */
  7. import { Context } from '@deepseek-ai/cordis'
  8. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  9. import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
  10. import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
  11. import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  12. import { z } from 'zod'
  13. /**
  14. * Fan-out bound on one remote `describe` batch. A settings page asks about the
  15. * references its own rows name, so this is far above any real page and still
  16. * keeps one authenticated request from starting unbounded provider work.
  17. */
  18. const MAX_DESCRIBE_REFS = 64
  19. const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
  20. const describeRequestSchema = z.object({
  21. refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS),
  22. })
  23. const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) })
  24. const unsetRequestSchema = z.object({ ref: credentialRefSchema })
  25. /** Parse the domain constraints that are more specific than generated TypeScript codecs. */
  26. function parseRequest<T>(method: string, schema: z.ZodType<T>, value: unknown): T {
  27. const parsed = schema.safeParse(value)
  28. if (!parsed.success) {
  29. throw new TypertRemoteFailure({
  30. code: 'bad-request',
  31. message: `invalid payload for ${method}`,
  32. details: { issues: parsed.error.issues },
  33. })
  34. }
  35. return parsed.data
  36. }
  37. /**
  38. * Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns
  39. * a business result without decoding it, so a provider whose `describe` carried
  40. * extra enumerable properties would otherwise serialize them to the caller.
  41. * @param info - the provider's answer for one reference.
  42. * @returns the same facts with nothing else attached.
  43. */
  44. function projectCredentialInfo(info: CredentialInfo): CredentialInfo {
  45. return {
  46. configured: info.configured,
  47. ...info.source === undefined ? {} : { source: info.source },
  48. writable: info.writable,
  49. }
  50. }
  51. declare module '@deepseek-ai/cordis' {
  52. interface Context {
  53. /** Host owner of the `credentials` Remote namespace. */
  54. credentialsController: CredentialsController
  55. }
  56. }
  57. /**
  58. * Host service backing the generated `ctx.remote.credentials` namespace. It
  59. * carries every wire obligation the credential seam itself does not: the batch
  60. * fan-out bound, the field-by-field view projection, the reference-grammar
  61. * guard, and the refusal mapping. Secret values cross in one direction only —
  62. * no method here returns one.
  63. */
  64. export class CredentialsController extends TypertRemoteService {
  65. /** @param ctx - Host context where a credential provider may be mounted. */
  66. constructor(ctx: Context) {
  67. super(ctx, 'credentialsController', { namespace: 'credentials' })
  68. }
  69. /**
  70. * Describe several references for one configuration surface. Batched because
  71. * a settings page describes every reference its rows name at once, and one
  72. * round trip keeps those rows from settling separately.
  73. * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.
  74. * @returns one view per requested name, keyed by that name.
  75. * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted.
  76. */
  77. @Remote
  78. async describe(refs: string[]): Promise<Record<string, CredentialInfo>> {
  79. const request = parseRequest('credentials.describe', describeRequestSchema, { refs })
  80. const branded = request.refs.map(ref => [ref, credentialRef(ref)] as const)
  81. const credentials = this.provider()
  82. const entries = await Promise.all(branded.map(async ([ref, key]) =>
  83. [ref, projectCredentialInfo(await credentials.describe(key))] as const))
  84. return Object.fromEntries(entries)
  85. }
  86. /**
  87. * Store one value from a configuration surface. The value crosses the wire in
  88. * this direction only: no read path returns it.
  89. * @param ref - reference name to store under.
  90. * @param value - the non-empty secret value.
  91. * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
  92. */
  93. @Remote
  94. async set(ref: string, value: string): Promise<void> {
  95. const request = parseRequest('credentials.set', setRequestSchema, { ref, value })
  96. const branded = credentialRef(request.ref)
  97. const credentials = this.provider()
  98. await this.write(request.ref, () => credentials.set(branded, request.value))
  99. }
  100. /**
  101. * Remove one reference from a configuration surface.
  102. * @param ref - reference name to remove.
  103. * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
  104. */
  105. @Remote
  106. async unset(ref: string): Promise<void> {
  107. const request = parseRequest('credentials.unset', unsetRequestSchema, { ref })
  108. const branded = credentialRef(request.ref)
  109. const credentials = this.provider()
  110. await this.write(request.ref, () => credentials.unset(branded))
  111. }
  112. /** Resolve the optional provider or report how to supply it. */
  113. private provider(): CredentialProvider {
  114. const credentials = this.ctx.get('credentials')
  115. if (credentials === undefined) {
  116. throw new TypertRemoteFailure({
  117. code: 'internal',
  118. message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
  119. details: {},
  120. })
  121. }
  122. return credentials
  123. }
  124. /**
  125. * Run one remote write and report every refusal as `credential-rejected`
  126. * carrying the seam's own message: a read-only source shadowing the reference
  127. * is what a configuration surface must show verbatim. Callers brand the
  128. * reference before entering, so a name outside the grammar never reaches this
  129. * path and fails the same way it does on the read side. The details name only
  130. * the reference, so no failure path can carry the value back out.
  131. */
  132. private async write(ref: string, write: () => Promise<void>): Promise<void> {
  133. try {
  134. await write()
  135. } catch (error: unknown) {
  136. throw new TypertRemoteFailure({
  137. code: 'credential-rejected',
  138. message: error instanceof Error ? error.message : String(error),
  139. details: { ref },
  140. })
  141. }
  142. }
  143. }
  144. export default CredentialsController