From 00431d7505b247af4ad07615f447cffbc2e525e7 Mon Sep 17 00:00:00 2001 From: James Gee <1285296+geemanjs@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:42:34 +0200 Subject: [PATCH] feat(core): Workflow export with credentials (#31241) --- ...kflow-with-credentials.integration.test.ts | 216 ++++++++++++++++ .../__tests__/utils/test-builders.ts | 68 +++++ .../credential-requirements.extractor.test.ts | 148 +++++++++++ .../__tests__/credential.exporter.test.ts | 238 ++++++++++++++++++ .../__tests__/credential.serializer.test.ts | 58 +++++ .../credential-requirements.extractor.ts | 29 +++ .../credential/credential.exporter.ts | 93 +++++++ .../credential/credential.serializer.ts | 18 ++ .../entities/credential/credential.types.ts | 6 + .../entities/requirements-extractor.ts | 5 + .../__tests__/workflow.exporter.test.ts | 75 ++++-- .../entities/workflow/workflow.exporter.ts | 42 ++-- .../io/__tests__/slug.utils.test.ts | 5 + .../unique-filename-allocator.test.ts | 48 ++++ .../io/__tests__/utils/capturing-writer.ts | 21 ++ .../src/modules/n8n-packages/io/slug.utils.ts | 6 +- .../io/unique-filename-allocator.ts | 27 ++ .../n8n-packages/n8n-packages.service.ts | 24 +- .../n8n-packages/spec/manifest.schema.ts | 4 + .../n8n-packages/spec/requirements.schema.ts | 15 ++ .../spec/serialized/credential.schema.ts | 11 + 21 files changed, 1103 insertions(+), 54 deletions(-) create mode 100644 packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/__tests__/utils/test-builders.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-requirements.extractor.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/credential-requirements.extractor.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/requirements-extractor.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts create mode 100644 packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts create mode 100644 packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts new file mode 100644 index 00000000000..b446e92c2ad --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts @@ -0,0 +1,216 @@ +import { + createTeamProject, + shareWorkflowWithUsers, + testDb, + testModules, +} from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; +import { jsonParse } from 'n8n-workflow'; + +import { saveCredential } from '@test-integration/db/credentials'; +import { createMember, createOwner } from '@test-integration/db/users'; + +import { N8nPackagesService } from '../n8n-packages.service'; +import { readExport } from './utils/tar-support'; +import { + buildWorkflowReferencingCredential, + buildWorkflowReferencingCredentialById, +} from './utils/test-builders'; + +beforeAll(async () => { + await testModules.loadModules(['n8n-packages']); + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +beforeEach(async () => { + await testDb.truncate([ + 'WorkflowEntity', + 'SharedWorkflow', + 'CredentialsEntity', + 'SharedCredentials', + 'ProjectRelation', + 'Project', + ]); +}); + +describe('workflow package export — with credentials', () => { + let service: N8nPackagesService; + + beforeAll(() => { + service = Container.get(N8nPackagesService); + }); + + it('bundles a referenced credential and lists it in manifest.credentials + requirements', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + const credential = await saveCredential( + { + name: 'Header credential', + type: 'httpHeaderAuth', + data: { name: 'X-Auth', value: 'secret' }, + }, + { project, role: 'credential:owner' }, + ); + const workflow = await buildWorkflowReferencingCredential({ + name: 'Workflow with creds', + project, + credential, + }); + + const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] }); + const { manifest, entries } = await readExport(stream); + + expect(manifest.credentials).toEqual([ + { + id: credential.id, + name: credential.name, + target: expect.any(String) as string, + }, + ]); + expect(manifest.requirements).toEqual({ + credentials: [ + { + id: credential.id, + name: credential.name, + type: 'httpHeaderAuth', + usedByWorkflows: [workflow.id], + }, + ], + }); + + const credentialFile = entries.find( + (e) => e.name === `${manifest.credentials![0].target}/credential.json`, + ); + expect(credentialFile).toBeDefined(); + const parsed = jsonParse>(credentialFile!.content.toString()); + expect(parsed).toEqual({ + id: credential.id, + name: credential.name, + type: 'httpHeaderAuth', + }); + // Secret-adjacent fields must not appear on disk under any name. + expect(Object.keys(parsed).sort()).toEqual(['id', 'name', 'type']); + }); + + it('dedupes a credential referenced by two workflows in a single export', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + const credential = await saveCredential( + { + name: 'Shared credential', + type: 'httpHeaderAuth', + data: { name: 'X-Auth', value: 'secret' }, + }, + { project, role: 'credential:owner' }, + ); + + const wfA = await buildWorkflowReferencingCredential({ + name: 'Workflow A', + project, + credential, + }); + const wfB = await buildWorkflowReferencingCredential({ + name: 'Workflow B', + project, + credential, + }); + + const stream = await service.exportWorkflows({ + user: owner, + workflowIds: [wfA.id, wfB.id], + }); + const { manifest, entries } = await readExport(stream); + + expect(manifest.credentials).toHaveLength(1); + expect(manifest.credentials![0].id).toBe(credential.id); + + expect(manifest.requirements?.credentials).toHaveLength(1); + expect(manifest.requirements!.credentials![0].usedByWorkflows.sort()).toEqual( + [wfA.id, wfB.id].sort(), + ); + + const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json')); + expect(credentialFiles).toHaveLength(1); + }); + + it('lists orphan credential references in requirements without writing a file', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + + const workflow = await buildWorkflowReferencingCredentialById({ + name: 'Workflow with orphan', + project, + credentialId: 'does-not-exist', + credentialName: 'Stale cred name', + credentialType: 'httpHeaderAuth', + }); + + const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] }); + const { manifest, entries } = await readExport(stream); + + expect(manifest.credentials).toBeUndefined(); + expect(manifest.requirements).toEqual({ + credentials: [ + { + id: 'does-not-exist', + name: 'Stale cred name', + type: 'httpHeaderAuth', + usedByWorkflows: [workflow.id], + }, + ], + }); + + const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json')); + expect(credentialFiles).toEqual([]); + }); + + it('emits a requirements-only entry when the caller can read the workflow but not its credential', async () => { + const owner = await createOwner(); + const ownerProject = await createTeamProject('Owner Project', owner); + const credential = await saveCredential( + { + name: 'Owner-only credential', + type: 'httpHeaderAuth', + data: { name: 'X-Auth', value: 'secret' }, + }, + { project: ownerProject, role: 'credential:owner' }, + ); + const workflow = await buildWorkflowReferencingCredential({ + name: 'Shared workflow with private cred', + project: ownerProject, + credential, + }); + + const sharee = await createMember(); + await shareWorkflowWithUsers(workflow, [sharee]); + + // The sharee can reach the workflow via the direct share, but the + // credential was never shared with them. The export must still succeed, + // recording the credential as a requirement using the name+type carried + // in the workflow JSON. + const stream = await service.exportWorkflows({ + user: sharee, + workflowIds: [workflow.id], + }); + const { manifest, entries } = await readExport(stream); + + expect(manifest.credentials).toBeUndefined(); + expect(manifest.requirements).toEqual({ + credentials: [ + { + id: credential.id, + name: credential.name, + type: 'httpHeaderAuth', + usedByWorkflows: [workflow.id], + }, + ], + }); + + const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json')); + expect(credentialFiles).toEqual([]); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/utils/test-builders.ts b/packages/cli/src/modules/n8n-packages/__tests__/utils/test-builders.ts new file mode 100644 index 00000000000..7f870402b54 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/utils/test-builders.ts @@ -0,0 +1,68 @@ +import { createWorkflow } from '@n8n/backend-test-utils'; +import type { CredentialsEntity, Project, WorkflowEntity } from '@n8n/db'; + +interface BuildWorkflowReferencingCredentialByIdOptions { + name: string; + project: Project; + credentialId: string; + credentialName: string; + credentialType: string; +} + +/** + * Creates a one-node workflow whose HTTP Request node references a credential + * by id without requiring the credential row to exist. Useful for orphan and + * forbidden-access cases. + */ +export async function buildWorkflowReferencingCredentialById({ + name, + project, + credentialId, + credentialName, + credentialType, +}: BuildWorkflowReferencingCredentialByIdOptions): Promise { + return await createWorkflow( + { + name, + nodes: [ + { + id: 'n1', + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { + [credentialType]: { id: credentialId, name: credentialName }, + }, + }, + ], + connections: {}, + }, + project, + ); +} + +interface BuildWorkflowReferencingCredentialOptions { + name: string; + project: Project; + credential: Pick; +} + +/** + * Convenience wrapper around `buildWorkflowReferencingCredentialById` for the + * common case of pointing at an already-saved credential. + */ +export async function buildWorkflowReferencingCredential({ + name, + project, + credential, +}: BuildWorkflowReferencingCredentialOptions): Promise { + return await buildWorkflowReferencingCredentialById({ + name, + project, + credentialId: credential.id, + credentialName: credential.name, + credentialType: credential.type, + }); +} diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-requirements.extractor.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-requirements.extractor.test.ts new file mode 100644 index 00000000000..f6c4cff230e --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-requirements.extractor.test.ts @@ -0,0 +1,148 @@ +import type { WorkflowEntity } from '@n8n/db'; + +import { CredentialRequirementsExtractor } from '../credential-requirements.extractor'; + +function makeWorkflow(overrides: Partial = {}): WorkflowEntity { + return { + id: 'wf-abc1234567', + name: 'My Workflow', + nodes: [], + connections: {}, + versionId: 'v1', + active: false, + isArchived: false, + settings: undefined, + parentFolder: null, + ...overrides, + } as unknown as WorkflowEntity; +} + +describe('CredentialRequirementsExtractor', () => { + const extractor = new CredentialRequirementsExtractor(); + + it('returns no requirements for a workflow with no credentialled nodes', () => { + const workflow = makeWorkflow({ + id: 'wf-no-creds', + nodes: [ + { + id: 'n1', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + }); + + expect(extractor.extract(workflow)).toEqual([]); + }); + + it('emits one requirement per node credential slot, keyed by credential type', () => { + // node.credentials is { [credentialTypeKey]: { id, name } } — the + // type comes from the map key, not the value. + const workflow = makeWorkflow({ + id: 'wf-creds', + nodes: [ + { + id: 'n1', + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { + httpHeaderAuth: { id: 'cred-1', name: 'Header credential' }, + httpBasicAuth: { id: 'cred-2', name: 'Basic credential' }, + }, + }, + ], + }); + + expect(extractor.extract(workflow)).toEqual( + expect.arrayContaining([ + { + workflowId: 'wf-creds', + credentialId: 'cred-1', + credentialName: 'Header credential', + credentialType: 'httpHeaderAuth', + }, + { + workflowId: 'wf-creds', + credentialId: 'cred-2', + credentialName: 'Basic credential', + credentialType: 'httpBasicAuth', + }, + ]), + ); + expect(extractor.extract(workflow)).toHaveLength(2); + }); + + it('dedupes when the same credential id appears in two nodes of one workflow', () => { + const workflow = makeWorkflow({ + id: 'wf-dup', + nodes: [ + { + id: 'n1', + name: 'HTTP A', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { + httpHeaderAuth: { id: 'cred-shared', name: 'Shared' }, + }, + }, + { + id: 'n2', + name: 'HTTP B', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { + httpHeaderAuth: { id: 'cred-shared', name: 'Shared' }, + }, + }, + ], + }); + + expect(extractor.extract(workflow)).toEqual([ + { + workflowId: 'wf-dup', + credentialId: 'cred-shared', + credentialName: 'Shared', + credentialType: 'httpHeaderAuth', + }, + ]); + }); + + it('skips slots that have no credential id selected yet', () => { + const workflow = makeWorkflow({ + id: 'wf-blank-slot', + nodes: [ + { + id: 'n1', + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { + httpHeaderAuth: { id: null as unknown as string, name: '' }, + }, + }, + ], + }); + + expect(extractor.extract(workflow)).toEqual([]); + }); + + it('returns an empty list when the workflow has no nodes array at all', () => { + // Defensive guard for older/partially-hydrated workflow rows where + // `nodes` may be absent rather than empty. + const workflow = makeWorkflow({ id: 'wf-no-nodes', nodes: undefined as unknown as [] }); + + expect(extractor.extract(workflow)).toEqual([]); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts new file mode 100644 index 00000000000..3b583d3efa4 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts @@ -0,0 +1,238 @@ +import type { CredentialsEntity, User } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; +import { jsonParse } from 'n8n-workflow'; + +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; + +import { CapturingWriter } from '../../../io/__tests__/utils/capturing-writer'; +import { CredentialExporter } from '../credential.exporter'; +import { CredentialSerializer } from '../credential.serializer'; +import type { WorkflowCredentialRequirement } from '../credential.types'; + +const user = mock({ id: 'user-1' }); + +function makeCredential(overrides: Partial = {}): CredentialsEntity { + return { + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + data: '', + isManaged: false, + isGlobal: false, + isResolvable: false, + resolvableAllowFallback: false, + resolverId: null, + shared: [], + ...overrides, + } as unknown as CredentialsEntity; +} + +function makeRequirement( + overrides: Partial = {}, +): WorkflowCredentialRequirement { + return { + workflowId: 'wf-1', + credentialId: 'cred-1', + credentialName: 'My Credential', + credentialType: 'httpHeaderAuth', + ...overrides, + }; +} + +function makeExporter() { + const finder = mock(); + const exporter = new CredentialExporter(finder, new CredentialSerializer()); + return { exporter, finder }; +} + +describe('CredentialExporter', () => { + describe('empty input', () => { + it('returns empty result and writes nothing when given no requirements', async () => { + const { exporter, finder } = makeExporter(); + const writer = new CapturingWriter(); + + const result = await exporter.export({ user, requirements: [], writer }); + + expect(result).toEqual({ entries: [], requirements: [] }); + expect(writer.files).toEqual([]); + expect(writer.directories).toEqual([]); + expect(finder.findCredentialForUser).not.toHaveBeenCalled(); + }); + }); + + describe('happy path', () => { + it('writes one accessible credential to its slugged folder and emits matching entry + requirement', async () => { + const { exporter, finder } = makeExporter(); + finder.findCredentialForUser.mockResolvedValue(makeCredential()); + const writer = new CapturingWriter(); + + const result = await exporter.export({ + user, + requirements: [makeRequirement()], + writer, + }); + + expect(finder.findCredentialForUser).toHaveBeenCalledWith('cred-1', user, [ + 'credential:read', + ]); + + expect(result.entries).toEqual([ + { id: 'cred-1', name: 'My Credential', target: 'credentials/my-credential' }, + ]); + expect(result.requirements).toEqual([ + { + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + usedByWorkflows: ['wf-1'], + }, + ]); + + expect(writer.directories).toEqual(['credentials/my-credential']); + expect(writer.files).toHaveLength(1); + expect(writer.files[0].path).toBe('credentials/my-credential/credential.json'); + + const parsed = jsonParse>(writer.files[0].content); + expect(parsed).toEqual({ + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + }); + }); + + it('dedupes by credential id and aggregates usedByWorkflows when requirements come from multiple workflows', async () => { + const { exporter, finder } = makeExporter(); + finder.findCredentialForUser.mockResolvedValue(makeCredential()); + const writer = new CapturingWriter(); + + const result = await exporter.export({ + user, + requirements: [ + makeRequirement({ workflowId: 'wf-a' }), + makeRequirement({ workflowId: 'wf-b' }), + ], + writer, + }); + + expect(finder.findCredentialForUser).toHaveBeenCalledTimes(1); + expect(result.entries).toEqual([ + { id: 'cred-1', name: 'My Credential', target: 'credentials/my-credential' }, + ]); + expect(result.requirements).toEqual([ + { + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + usedByWorkflows: ['wf-a', 'wf-b'], + }, + ]); + expect(writer.files).toHaveLength(1); + }); + + it('disambiguates targets when two credentials share a name', async () => { + const { exporter, finder } = makeExporter(); + finder.findCredentialForUser + .mockResolvedValueOnce(makeCredential({ id: 'cred-a', name: 'Same Name' })) + .mockResolvedValueOnce(makeCredential({ id: 'cred-b', name: 'Same Name' })); + const writer = new CapturingWriter(); + + const result = await exporter.export({ + user, + requirements: [ + makeRequirement({ credentialId: 'cred-a', credentialName: 'Same Name' }), + makeRequirement({ credentialId: 'cred-b', credentialName: 'Same Name' }), + ], + writer, + }); + + const targets = result.entries.map((e) => e.target); + expect(targets).toEqual(['credentials/same-name', 'credentials/same-name-2']); + + const writtenPaths = writer.files.map((f) => f.path); + expect(writtenPaths).toContain('credentials/same-name/credential.json'); + expect(writtenPaths).toContain('credentials/same-name-2/credential.json'); + }); + + it('emits a requirements-only entry when the credential is unfindable for the caller', async () => { + // findCredentialForUser returns null for both "row missing" and + // "row exists but the caller lacks credential:read"; the unit + // can't (and shouldn't) distinguish them. Either way the + // exporter must still pass the credential:read scope so the + // finder applies the right policy. + const { exporter, finder } = makeExporter(); + finder.findCredentialForUser.mockResolvedValue(null); + const writer = new CapturingWriter(); + + const result = await exporter.export({ + user, + requirements: [ + makeRequirement({ + credentialId: 'cred-unavailable', + credentialName: 'Stale node name', + credentialType: 'httpHeaderAuth', + workflowId: 'wf-1', + }), + ], + writer, + }); + + expect(finder.findCredentialForUser).toHaveBeenCalledWith('cred-unavailable', user, [ + 'credential:read', + ]); + + expect(result.entries).toEqual([]); + expect(result.requirements).toEqual([ + { + id: 'cred-unavailable', + name: 'Stale node name', + type: 'httpHeaderAuth', + usedByWorkflows: ['wf-1'], + }, + ]); + expect(writer.files).toEqual([]); + expect(writer.directories).toEqual([]); + }); + + it('handles a mix of accessible and unavailable requirements in one call', async () => { + const { exporter, finder } = makeExporter(); + finder.findCredentialForUser.mockImplementation(async (id) => { + await Promise.resolve(); + return id === 'cred-1' ? makeCredential() : null; + }); + const writer = new CapturingWriter(); + + const result = await exporter.export({ + user, + requirements: [ + makeRequirement({ credentialId: 'cred-1', workflowId: 'wf-1' }), + makeRequirement({ + credentialId: 'cred-unavailable', + credentialName: 'Unavailable', + credentialType: 'slackOAuth2Api', + workflowId: 'wf-1', + }), + ], + writer, + }); + + expect(result.entries).toEqual([ + { id: 'cred-1', name: 'My Credential', target: 'credentials/my-credential' }, + ]); + expect(result.requirements).toEqual([ + { + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + usedByWorkflows: ['wf-1'], + }, + { + id: 'cred-unavailable', + name: 'Unavailable', + type: 'slackOAuth2Api', + usedByWorkflows: ['wf-1'], + }, + ]); + expect(writer.files).toHaveLength(1); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts new file mode 100644 index 00000000000..5a65ff88d09 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts @@ -0,0 +1,58 @@ +import type { CredentialsEntity } from '@n8n/db'; + +import { CredentialSerializer } from '../credential.serializer'; + +function makeCredential(overrides: Partial = {}): CredentialsEntity { + return { + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + data: 'encrypted-blob-do-not-export', + isManaged: false, + isGlobal: false, + isResolvable: false, + resolvableAllowFallback: false, + resolverId: null, + createdAt: new Date(), + updatedAt: new Date(), + shared: [], + ...overrides, + } as unknown as CredentialsEntity; +} + +describe('CredentialSerializer', () => { + const serializer = new CredentialSerializer(); + + it('returns exactly id, name, type', () => { + const credential = makeCredential(); + + const serialized = serializer.serialize(credential); + + expect(Object.keys(serialized).sort()).toEqual(['id', 'name', 'type']); + expect(serialized).toEqual({ + id: 'cred-1', + name: 'My Credential', + type: 'httpHeaderAuth', + }); + }); + + it('does not leak encrypted data or secret-adjacent flags', () => { + const credential = makeCredential({ + data: 'sensitive-encrypted-payload', + isManaged: true, + isGlobal: true, + }); + + const serialized = serializer.serialize(credential); + + const serializedAsRecord = serialized as unknown as Record; + expect(serializedAsRecord.data).toBeUndefined(); + expect(serializedAsRecord.isManaged).toBeUndefined(); + expect(serializedAsRecord.isGlobal).toBeUndefined(); + expect(serializedAsRecord.isResolvable).toBeUndefined(); + expect(serializedAsRecord.resolverId).toBeUndefined(); + expect(serializedAsRecord.shared).toBeUndefined(); + expect(serializedAsRecord.createdAt).toBeUndefined(); + expect(serializedAsRecord.updatedAt).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-requirements.extractor.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-requirements.extractor.ts new file mode 100644 index 00000000000..378aa67e10f --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-requirements.extractor.ts @@ -0,0 +1,29 @@ +import type { WorkflowEntity } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import type { WorkflowCredentialRequirement } from './credential.types'; +import type { RequirementsExtractor } from '../requirements-extractor'; + +@Service() +export class CredentialRequirementsExtractor + implements RequirementsExtractor +{ + extract(workflow: WorkflowEntity): WorkflowCredentialRequirement[] { + const byId = new Map(); + + for (const node of workflow.nodes ?? []) { + for (const [credentialType, details] of Object.entries(node.credentials ?? {})) { + if (!details?.id || byId.has(details.id)) continue; + + byId.set(details.id, { + workflowId: workflow.id, + credentialId: details.id, + credentialName: details.name, + credentialType, + }); + } + } + + return [...byId.values()]; + } +} diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts new file mode 100644 index 00000000000..3fb8d17fe80 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts @@ -0,0 +1,93 @@ +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; + +import { CredentialSerializer } from './credential.serializer'; +import type { WorkflowCredentialRequirement } from './credential.types'; +import type { PackageWriter } from '../../io/package-writer'; +import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator'; +import type { ManifestEntry } from '../../spec/manifest.schema'; +import type { PackageCredentialRequirement } from '../../spec/requirements.schema'; + +interface CredentialGroup { + // Name+type to use if the DB lookup fails — sourced from the workflow snapshot. + fallback: WorkflowCredentialRequirement; + usedByWorkflows: string[]; +} + +export interface CredentialExportRequest { + user: User; + requirements: WorkflowCredentialRequirement[]; + writer: PackageWriter; +} + +export interface CredentialExportResult { + entries: ManifestEntry[]; + requirements: PackageCredentialRequirement[]; +} + +@Service() +export class CredentialExporter { + constructor( + private readonly credentialsFinder: CredentialsFinderService, + private readonly credentialSerializer: CredentialSerializer, + ) {} + + async export(request: CredentialExportRequest): Promise { + const allocator = new UniqueFilenameAllocator('credentials', 'credential'); + const entries: ManifestEntry[] = []; + const requirements: PackageCredentialRequirement[] = []; + + for (const [credentialId, { fallback, usedByWorkflows }] of this.groupByCredentialId( + request.requirements, + )) { + const credential = await this.credentialsFinder.findCredentialForUser( + credentialId, + request.user, + ['credential:read'], + ); + + // Use the workflow data if we can't fetch the full credential + const { id, name, type } = credential ?? { + id: credentialId, + name: fallback.credentialName, + type: fallback.credentialType, + }; + + if (credential) { + const target = allocator.allocate(name); + request.writer.writeDirectory(target); + request.writer.writeFile( + `${target}/credential.json`, + JSON.stringify(this.credentialSerializer.serialize(credential), null, '\t'), + ); + entries.push({ id, name, target }); + } + + requirements.push({ id, name, type, usedByWorkflows }); + } + + return { entries, requirements }; + } + + private groupByCredentialId( + requirements: WorkflowCredentialRequirement[], + ): Map { + const grouped = new Map(); + for (const requirement of requirements) { + const existing = grouped.get(requirement.credentialId); + if (existing) { + if (!existing.usedByWorkflows.includes(requirement.workflowId)) { + existing.usedByWorkflows.push(requirement.workflowId); + } + } else { + grouped.set(requirement.credentialId, { + fallback: requirement, + usedByWorkflows: [requirement.workflowId], + }); + } + } + return grouped; + } +} diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts new file mode 100644 index 00000000000..af5c1bfeb6e --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts @@ -0,0 +1,18 @@ +import type { CredentialsEntity } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { + serializedCredentialSchema, + type SerializedCredential, +} from '../../spec/serialized/credential.schema'; + +@Service() +export class CredentialSerializer { + serialize(credential: CredentialsEntity): SerializedCredential { + return serializedCredentialSchema.parse({ + id: credential.id, + name: credential.name, + type: credential.type, + }); + } +} diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts new file mode 100644 index 00000000000..f3b85cebdbf --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts @@ -0,0 +1,6 @@ +export interface WorkflowCredentialRequirement { + workflowId: string; + credentialId: string; + credentialName: string; + credentialType: string; +} diff --git a/packages/cli/src/modules/n8n-packages/entities/requirements-extractor.ts b/packages/cli/src/modules/n8n-packages/entities/requirements-extractor.ts new file mode 100644 index 00000000000..c684da1534f --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/requirements-extractor.ts @@ -0,0 +1,5 @@ +import type { WorkflowEntity } from '@n8n/db'; + +export interface RequirementsExtractor { + extract(workflow: WorkflowEntity): TRequirement[]; +} diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts index 4070e3c6aac..a52fb5360b4 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts @@ -1,10 +1,11 @@ import type { User, WorkflowEntity } from '@n8n/db'; import { mock } from 'jest-mock-extended'; -import type { Readable } from 'node:stream'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import type { PackageWriter } from '../../../io/package-writer'; +import { CapturingWriter } from '../../../io/__tests__/utils/capturing-writer'; +import { CredentialRequirementsExtractor } from '../../credential/credential-requirements.extractor'; +import type { WorkflowCredentialRequirement } from '../../credential/credential.types'; import { WorkflowExporter } from '../workflow.exporter'; import { WorkflowSerializer } from '../workflow.serializer'; @@ -25,28 +26,14 @@ function makeWorkflow(overrides: Partial = {}): WorkflowEntity { } as unknown as WorkflowEntity; } -class CapturingWriter implements PackageWriter { - readonly files: Array<{ path: string; content: string }> = []; - - readonly directories: string[] = []; - - writeFile(path: string, content: string | Buffer): void { - this.files.push({ path, content: content.toString() }); - } - - writeDirectory(path: string): void { - this.directories.push(path); - } - - finalize(): Readable { - throw new Error('not used in this test'); - } -} - -function makeExporter(returned: WorkflowEntity[]) { +function makeExporter(returned: WorkflowEntity[], extractor?: CredentialRequirementsExtractor) { const finder = mock(); finder.findWorkflowsByIdsForUser.mockResolvedValue(returned); - const exporter = new WorkflowExporter(finder, new WorkflowSerializer()); + const exporter = new WorkflowExporter( + finder, + new WorkflowSerializer(), + extractor ?? new CredentialRequirementsExtractor(), + ); return { exporter, finder }; } @@ -87,7 +74,7 @@ describe('WorkflowExporter', () => { const { exporter } = makeExporter([workflow]); const writer = new CapturingWriter(); - const entries = await exporter.export({ + const { entries } = await exporter.export({ user, workflowIds: [workflow.id, workflow.id], writer, @@ -107,7 +94,7 @@ describe('WorkflowExporter', () => { const { exporter } = makeExporter([a, b]); const writer = new CapturingWriter(); - const entries = await exporter.export({ user, workflowIds: [a.id, b.id], writer }); + const { entries } = await exporter.export({ user, workflowIds: [a.id, b.id], writer }); const targets = entries.map((e) => e.target); expect(targets).toEqual(['workflows/same-name', 'workflows/same-name-2']); @@ -116,4 +103,44 @@ describe('WorkflowExporter', () => { expect(writtenPaths).toContain('workflows/same-name/workflow.json'); expect(writtenPaths).toContain('workflows/same-name-2/workflow.json'); }); + + it('runs the extractor on each workflow and concatenates the results into requirements.credentials', async () => { + // Per-workflow extraction logic lives in CredentialRequirementsExtractor's + // own suite; this test only proves the exporter wires the extractor in. + const a = makeWorkflow({ id: 'wf-a' }); + const b = makeWorkflow({ id: 'wf-b' }); + const extractor = mock(); + extractor.extract.mockImplementation((workflow) => [ + { + workflowId: workflow.id, + credentialId: `cred-from-${workflow.id}`, + credentialName: workflow.id, + credentialType: 'httpHeaderAuth', + }, + ]); + const { exporter } = makeExporter([a, b], extractor); + const writer = new CapturingWriter(); + + const { requirements } = await exporter.export({ + user, + workflowIds: [a.id, b.id], + writer, + }); + + expect(extractor.extract).toHaveBeenCalledTimes(2); + expect(requirements.credentials).toEqual([ + { + workflowId: 'wf-a', + credentialId: 'cred-from-wf-a', + credentialName: 'wf-a', + credentialType: 'httpHeaderAuth', + }, + { + workflowId: 'wf-b', + credentialId: 'cred-from-wf-b', + credentialName: 'wf-b', + credentialType: 'httpHeaderAuth', + }, + ]); + }); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts index bedd48d41ec..51e21b2c696 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts @@ -6,8 +6,10 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowSerializer } from './workflow.serializer'; import type { PackageWriter } from '../../io/package-writer'; -import { generateSlug } from '../../io/slug.utils'; +import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator'; import type { ManifestEntry } from '../../spec/manifest.schema'; +import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor'; +import type { WorkflowCredentialRequirement } from '../credential/credential.types'; export interface WorkflowExportRequest { user: User; @@ -15,14 +17,24 @@ export interface WorkflowExportRequest { writer: PackageWriter; } +export interface WorkflowExportRequirements { + credentials: WorkflowCredentialRequirement[]; +} + +export interface WorkflowExportResult { + entries: ManifestEntry[]; + requirements: WorkflowExportRequirements; +} + @Service() export class WorkflowExporter { constructor( private readonly workflowFinder: WorkflowFinderService, private readonly workflowSerializer: WorkflowSerializer, + private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor, ) {} - async export(request: WorkflowExportRequest): Promise { + async export(request: WorkflowExportRequest): Promise { const workflows = await this.workflowFinder.findWorkflowsByIdsForUser( request.workflowIds, request.user, @@ -33,10 +45,11 @@ export class WorkflowExporter { this.assertAllRequestedWorkflowsFound(request.workflowIds, workflows); const entries: ManifestEntry[] = []; - const usedTargets = new Set(); + const credentials: WorkflowCredentialRequirement[] = []; + const fileNames = new UniqueFilenameAllocator('workflows'); for (const workflow of workflows) { - const target = this.allocateUniqueFileName(workflow.name, usedTargets); + const target = fileNames.allocate(workflow.name); const serialized = this.workflowSerializer.serialize(workflow); request.writer.writeDirectory(target); @@ -47,26 +60,11 @@ export class WorkflowExporter { name: workflow.name, target, }); + + credentials.push(...this.credentialRequirementsExtractor.extract(workflow)); } - return entries; - } - - private allocateUniqueFileName(name: string, used: Set): string { - const base = `workflows/${generateSlug(name)}`; - - if (!used.has(base)) { - used.add(base); - return base; - } - - for (let suffix = 2; ; suffix++) { - const candidate = `${base}-${suffix}`; - if (!used.has(candidate)) { - used.add(candidate); - return candidate; - } - } + return { entries, requirements: { credentials } }; } private assertAllRequestedWorkflowsFound( diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts index 4c04ea82382..5f91ba41376 100644 --- a/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts @@ -17,6 +17,11 @@ describe('generateSlug', () => { expect(generateSlug('--')).toBe('workflow'); }); + it('uses a caller-provided fallback when the name yields an empty slug', () => { + expect(generateSlug('--', 'credential')).toBe('credential'); + expect(generateSlug('', 'credential')).toBe('credential'); + }); + it('strips leading and trailing hyphens', () => { expect(generateSlug('---foo---')).toBe('foo'); }); diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts new file mode 100644 index 00000000000..3ba19aa4f22 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts @@ -0,0 +1,48 @@ +import { UniqueFilenameAllocator } from '../unique-filename-allocator'; + +describe('UniqueFilenameAllocator', () => { + it('returns the slugged base path on first allocation', () => { + const allocator = new UniqueFilenameAllocator('workflows'); + + expect(allocator.allocate('My Workflow')).toBe('workflows/my-workflow'); + }); + + it('suffixes -2 when a slug collides with an earlier allocation', () => { + const allocator = new UniqueFilenameAllocator('workflows'); + + allocator.allocate('Same Name'); + expect(allocator.allocate('Same Name')).toBe('workflows/same-name-2'); + }); + + it('keeps incrementing the suffix until it finds a free slot', () => { + const allocator = new UniqueFilenameAllocator('workflows'); + + allocator.allocate('Same Name'); + allocator.allocate('Same Name'); + expect(allocator.allocate('Same Name')).toBe('workflows/same-name-3'); + }); + + it('uses the supplied fallback when the name slugifies to an empty string', () => { + const allocator = new UniqueFilenameAllocator('credentials', 'credential'); + + expect(allocator.allocate('!!!')).toBe('credentials/credential'); + }); + + it('suffixes the fallback when two unslugifiable names collide', () => { + // Two credentials named entirely with stripped characters both + // fall back to the same slug; the second one must still get a + // unique target rather than overwriting the first. + const allocator = new UniqueFilenameAllocator('credentials', 'credential'); + + expect(allocator.allocate('!!!')).toBe('credentials/credential'); + expect(allocator.allocate('???')).toBe('credentials/credential-2'); + }); + + it('keeps the per-instance used set independent across allocators', () => { + const a = new UniqueFilenameAllocator('workflows'); + const b = new UniqueFilenameAllocator('workflows'); + + expect(a.allocate('Same Name')).toBe('workflows/same-name'); + expect(b.allocate('Same Name')).toBe('workflows/same-name'); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts new file mode 100644 index 00000000000..47266d80138 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts @@ -0,0 +1,21 @@ +import type { Readable } from 'node:stream'; + +import type { PackageWriter } from '../../package-writer'; + +export class CapturingWriter implements PackageWriter { + readonly files: Array<{ path: string; content: string }> = []; + + readonly directories: string[] = []; + + writeFile(path: string, content: string | Buffer): void { + this.files.push({ path, content: content.toString() }); + } + + writeDirectory(path: string): void { + this.directories.push(path); + } + + finalize(): Readable { + throw new Error('CapturingWriter is for unit tests and does not produce a stream'); + } +} diff --git a/packages/cli/src/modules/n8n-packages/io/slug.utils.ts b/packages/cli/src/modules/n8n-packages/io/slug.utils.ts index 76694daf2d3..3052f9ad14d 100644 --- a/packages/cli/src/modules/n8n-packages/io/slug.utils.ts +++ b/packages/cli/src/modules/n8n-packages/io/slug.utils.ts @@ -1,9 +1,9 @@ -const EMPTY_SLUG_FALLBACK = 'workflow'; +const DEFAULT_EMPTY_SLUG_FALLBACK = 'workflow'; /** * Generates a filesystem-safe slug from an entity name */ -export function generateSlug(name: string): string { +export function generateSlug(name: string, fallback: string = DEFAULT_EMPTY_SLUG_FALLBACK): string { let slug = name; slug = slug .toLowerCase() @@ -18,5 +18,5 @@ export function generateSlug(name: string): string { // Remove any - at the start or end of the slug .replace(/^-|-$/g, ''); - return slug || EMPTY_SLUG_FALLBACK; + return slug || fallback; } diff --git a/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts b/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts new file mode 100644 index 00000000000..c8ba3b54875 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts @@ -0,0 +1,27 @@ +import { generateSlug } from './slug.utils'; + +export class UniqueFilenameAllocator { + private readonly used = new Set(); + + constructor( + private readonly baseDir: string, + private readonly fallback?: string, + ) {} + + allocate(name: string): string { + const base = `${this.baseDir}/${generateSlug(name, this.fallback)}`; + + if (!this.used.has(base)) { + this.used.add(base); + return base; + } + + for (let suffix = 2; ; suffix++) { + const candidate = `${base}-${suffix}`; + if (!this.used.has(candidate)) { + this.used.add(candidate); + return candidate; + } + } + } +} diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index 620ba23d243..800b80280d0 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -4,6 +4,7 @@ import type { Readable } from 'node:stream'; import { N8N_VERSION } from '@/constants'; +import { CredentialExporter } from './entities/credential/credential.exporter'; import { WorkflowExporter } from './entities/workflow/workflow.exporter'; import { ImportPipeline } from './engine/import-pipeline'; import { TarPackageWriter } from './io/tar/tar-package-writer'; @@ -19,6 +20,7 @@ import { packageManifestSchema } from './spec/manifest.schema'; export class N8nPackagesService { constructor( private readonly workflowExporter: WorkflowExporter, + private readonly credentialExporter: CredentialExporter, private readonly instanceSettings: InstanceSettings, private readonly importPipeline: ImportPipeline, ) {} @@ -26,11 +28,19 @@ export class N8nPackagesService { async exportWorkflows(request: ExportWorkflowsRequest): Promise { const writer = new TarPackageWriter(); - const workflowEntries = await this.workflowExporter.export({ - user: request.user, - workflowIds: request.workflowIds, - writer, - }); + const { entries: workflowEntries, requirements: workflowRequirements } = + await this.workflowExporter.export({ + user: request.user, + workflowIds: request.workflowIds, + writer, + }); + + const { entries: credentialEntries, requirements: credentialRequirements } = + await this.credentialExporter.export({ + user: request.user, + requirements: workflowRequirements.credentials, + writer, + }); const manifest = packageManifestSchema.parse({ packageFormatVersion: FORMAT_VERSION, @@ -38,6 +48,10 @@ export class N8nPackagesService { sourceN8nVersion: N8N_VERSION, sourceId: this.instanceSettings.instanceId, workflows: workflowEntries, + ...(credentialEntries.length > 0 ? { credentials: credentialEntries } : {}), + ...(credentialRequirements.length > 0 + ? { requirements: { credentials: credentialRequirements } } + : {}), }); writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t')); diff --git a/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts b/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts index 76f637328c9..2eb5da013f1 100644 --- a/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts +++ b/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { FORMAT_VERSION } from './constants'; +import { packageRequirementsSchema } from './requirements.schema'; + export const manifestEntrySchema = z.object({ id: z.string().min(1), name: z.string(), @@ -15,6 +17,8 @@ export const packageManifestSchema = z sourceN8nVersion: z.string().min(1), sourceId: z.string().min(1), workflows: z.array(manifestEntrySchema).optional(), + credentials: z.array(manifestEntrySchema).optional(), + requirements: packageRequirementsSchema.optional(), }) .superRefine((manifest, ctx) => { if (!manifest.workflows) return; diff --git a/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts b/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts new file mode 100644 index 00000000000..47adfe054bb --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const packageCredentialRequirementSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + type: z.string().min(1), + usedByWorkflows: z.array(z.string().min(1)).min(1), +}); + +export const packageRequirementsSchema = z.object({ + credentials: z.array(packageCredentialRequirementSchema).optional(), +}); + +export type PackageCredentialRequirement = z.infer; +export type PackageRequirements = z.infer; diff --git a/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts b/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts new file mode 100644 index 00000000000..4de23752dd1 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const serializedCredentialSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + type: z.string().min(1), + }) + .strict(); + +export type SerializedCredential = z.infer;