schema.ts 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041
  1. import { z } from 'zod'
  2. import { HttpError } from '../errors.js'
  3. export const website = z.object({ kind: z.literal('website'), origin: z.string().max(2000).transform((value, ctx) => {
  4. try {
  5. const u = new URL(value)
  6. if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) throw new Error()
  7. return u.origin
  8. } catch { ctx.addIssue({ code: 'custom', message: '网站需要准确的 HTTPS origin' }); return z.NEVER }
  9. }) }).strict()
  10. export const ssh = z.object({ kind: z.literal('ssh'), host: z.string().min(1).max(253).regex(/^[A-Za-z0-9.:[\]_-]+$/).transform(v => v.toLowerCase()),
  11. port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/) }).strict()
  12. export const target = z.discriminatedUnion('kind', [website, ssh])
  13. const path = z.string().min(2).max(1024).refine(v=>v.startsWith('/')&&!v.includes('\\')&&!/[\u0000-\u001f\u007f?#]/.test(v)&&v.split('/').every((s,i)=>i===0||!!s&&s!=='.'&&s!=='..'))
  14. const name = z.string().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_.:-]*$/)
  15. const form = z.object({kind:z.literal('website.form'),loginPath:path,submitPath:path,successPath:path,logoutPath:path.optional(),formId:name,usernameName:name,passwordName:name,
  16. successSelector:z.string().min(1).max(256).regex(/^(#[A-Za-z][A-Za-z0-9_-]*|\[data-[a-z0-9-]+(?:="[A-Za-z0-9_-]+")?\])$/)}).strict()
  17. const command = z.object({kind:z.literal('ssh.exec'),command:z.string().max(512).regex(/^\/[A-Za-z0-9_/-]+$/).refine(v=>!v.includes('//')&&!v.endsWith('/'))}).strict()
  18. const read = z.object({kind:z.literal('sftp.read'),remotePath:path,maxBytes:z.number().int().min(1).max(1048576).default(65536)}).strict()
  19. const write = z.object({kind:z.literal('sftp.write'),remotePath:path,contents:z.string().max(65536)}).strict()
  20. export const usage = z.discriminatedUnion('kind',[form,command,read,write])
  21. const updatedUsage = z.discriminatedUnion('kind',[form,command,read,write.extend({contents:z.string().max(65536).optional()})])
  22. export const leaseInput = z.object({
  23. credentialRef: z.string().uuid(), agentId: z.string().uuid(), workId: z.string().uuid(),
  24. runnerId: z.string().uuid(), runnerInstance: z.string().uuid(), runnerEpoch: z.string().min(1).max(128),
  25. operation: z.enum(['website.login', 'ssh.exec', 'sftp.read', 'sftp.write']), target,
  26. seconds: z.number().int().min(15).max(120).default(60),
  27. }).strict()
  28. export type LeaseInput = z.infer<typeof leaseInput>
  29. export const entryInput = z.object({ name: z.string().trim().min(1).max(120), username: z.string().min(1).max(256),
  30. target, usage:usage.optional(), secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
  31. export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), usage:updatedUsage.optional(), revision: z.number().int().positive() })
  32. export const storedEntry = entryInput.extend({ id: z.string().uuid(), revision: z.number().int().positive(), updatedAt: z.number().int().nonnegative() })
  33. export type VaultEntry = z.infer<typeof storedEntry>
  34. export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
  35. const result = schema.safeParse(value)
  36. if (!result.success) throw new HttpError(400, '密码库请求格式无效', 'vault_invalid_request')
  37. return result.data
  38. }
  39. export function masterPassword(value: unknown): string {
  40. return parse(z.string().min(12).max(1024), value)
  41. }